{"repo": "cecwxf/wtt-web", "files": {"app/feed/page.tsx": {"da26680f49538a590680ceb96a49ed6ac0f5e222": {"t": 1785747475.6385202, "n": 134903, "h": "a0370a954835d189", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n applyMessageStreamEvent,\n parseMessageStreamEvent,\n removeStreamPlaceholderForFinalMessage,\n streamIdFromMessageRecord,\n} from '@/lib/message-stream'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\nimport { mergeMessageHistory } from '@/lib/chat-history'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n ends_at?: string | null\n is_trial?: boolean\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\nfunction formatProTrialText(endsAt?: string | null): string {\n if (!endsAt) return '你正在使用新用户 Pro 试用,可使用 Cloud Agent、Arena 和 Studio。'\n const expiresAt = new Date(endsAt).getTime()\n if (!Number.isFinite(expiresAt)) return '你正在使用新用户 Pro 试用,可使用 Cloud Agent、Arena 和 Studio。'\n const remainingMs = expiresAt - Date.now()\n if (remainingMs <= 0) return 'Pro 试用已结束,续费后可继续使用 Cloud Agent、Arena 和 Studio。'\n const days = Math.ceil(remainingMs / 86_400_000)\n return `新用户 Pro 试用剩余 ${days} 天。试用结束后将自动回到 Free,续费后可继续使用 Cloud Agent、Arena 和 Studio。`\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n const merged = mergeMessageHistory(readCachedTopicMessages(topicId, agentId), data)\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data: merged }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : {}\n\n const model = String(\n cfg.model\n || meta.model\n || meta.current_model\n || meta.currentModel\n || meta.model_id\n || meta.modelId\n || '',\n ).trim()\n const effortRaw = String(\n cfg.reasoning_effort\n || cfg.reasoningEffort\n || meta.reasoning_effort\n || meta.reasoningEffort\n || meta.thinking_mode\n || meta.thinkingMode\n || '',\n ).trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const { data: personalKbRaw, mutate: mutatePersonalKb } = useSWR(\n session?.accessToken ? ['personal-kb-root', session.accessToken] : null,\n async () => {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!resp.ok) return null\n return resp.json()\n },\n { revalidateOnFocus: false, dedupingInterval: 60000 }\n )\n const personalKbTaskId = useMemo(() => {\n const raw = personalKbRaw as Record | null | undefined\n return raw?.id ? String(raw.id) : undefined\n }, [personalKbRaw])\n\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n mutatePersonalKb(kb, false)\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading, mutatePersonalKb])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&include_history=true&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n // Per-topic fallbackData already prevents blank loading states. Reusing the\n // previous SWR key can briefly render another topic's history here.\n keepPreviousData: false,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const streamEvent = parseMessageStreamEvent(rawEvent)\n if (streamEvent) {\n setAllMessages((prev) => applyMessageStreamEvent(prev, streamEvent, {\n topicId: selectedTopicId || undefined,\n displayName: (senderId) => agentNameMap[senderId] || senderId,\n }))\n if (streamEvent.topic_id === selectedTopicId && streamEvent.sender_id) {\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, streamEvent.topic_id, streamEvent.sender_id, new Date().toISOString()))\n }\n return\n }\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const streamId = streamIdFromMessageRecord(msg.message)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n stream_id: streamId || undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n const withoutStream = removeStreamPlaceholderForFinalMessage(prev, msg.message)\n if (withoutStream.some((m) => m.message_id === incoming.message_id)) return withoutStream\n return [...withoutStream, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged) {\n setAllMessages(normalized)\n setHasOlder(normalized.length >= 100)\n } else if (normalized.length > 0) {\n setAllMessages((prev) => mergeMessageHistory(prev, normalized))\n setHasOlder((previous) => previous || normalized.length >= 100)\n }\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n const clearRecentTopicUnread = useCallback((topicId: string) => {\n void mutateRecentTopics((current: unknown) => {\n const clearItem = (item: unknown) => {\n if (!item || typeof item !== 'object') return item\n const row = item as Record\n const id = String(row.topic_id ?? row.id ?? '')\n return id === topicId ? { ...row, unread_count: 0 } : item\n }\n\n if (Array.isArray(current)) return current.map(clearItem)\n if (current && typeof current === 'object') {\n const payload = current as Record\n if (Array.isArray(payload.items)) {\n return { ...payload, items: payload.items.map(clearItem) }\n }\n }\n return current\n }, false)\n }, [mutateRecentTopics])\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n void mutateRecentTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .filter((row) => !shouldHideFeedTopic(row as Record))\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n const selectedTopicKnowledgeTargetAgentId = useMemo(() => (\n selectedTopic?.runner_agent_id || topicActorAgentId || selectedAgentId\n ), [selectedAgentId, selectedTopic?.runner_agent_id, topicActorAgentId])\n const selectedTopicKnowledgeContextType = selectedTopicTaskId ? 'task' : 'chat'\n const canUseKnowledgeMode = Boolean(\n personalKbTaskId &&\n selectedTopic &&\n selectedTopicKnowledgeTargetAgentId\n )\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const proTrialActive = billingPlan === 'pro' && Boolean(billingRaw?.entitlement?.is_trial)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n target_agent_id: aid,\n content: '[system:p2p_init]',\n content_type: 'text',\n semantic_type: 'system',\n metadata: { cloud_no_auto_wake: true },\n }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n const metadata: Record = {}\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n if (options?.commandFamily) metadata.command_family = options.commandFamily\n if (options?.skillId) metadata.skill_id = options.skillId\n }\n if (options?.kbMode && options.kbTaskId) {\n metadata.kb_mode = true\n metadata.kb_scope = options.kbScope || 'personal'\n metadata.kb_task_id = options.kbTaskId\n metadata.kb_context_type = options.kbContextType || (isTask ? 'task' : 'chat')\n metadata.kb_target_agent_id = options.kbTargetAgentId || agentIdForSend\n metadata.kb_query = options.kbQuery || content\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n clearRecentTopicUnread(topicId)\n }, [clearRecentTopicUnread, setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {proTrialActive && (\n
\n
\n {formatProTrialText(billingRaw?.entitlement?.ends_at)}\n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-emerald-300 bg-white px-3 py-1 text-[11px] font-bold text-emerald-800 shadow-sm transition hover:bg-emerald-100 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-100 dark:hover:bg-emerald-400/20\"\n >\n 转为正式 Pro\n \n
\n
\n )}\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n enableCameraCapture\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n canUseKnowledgeMode={canUseKnowledgeMode}\n knowledgeTaskId={personalKbTaskId}\n knowledgeTargetAgentId={selectedTopicKnowledgeTargetAgentId}\n knowledgeContextType={selectedTopicKnowledgeContextType}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "dabb41104f2b9d22d4a6829001603a94bd126bb1": {"t": 1785773532.0257928, "n": 135279, "h": "4bdd593fa644d3ce", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n applyMessageStreamEvent,\n parseMessageStreamEvent,\n removeStreamPlaceholderForFinalMessage,\n streamIdFromMessageRecord,\n} from '@/lib/message-stream'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n ends_at?: string | null\n is_trial?: boolean\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\nfunction formatProTrialText(endsAt?: string | null): string {\n if (!endsAt) return '你正在使用新用户 Pro 试用,可使用 Cloud Agent、Arena 和 Studio。'\n const expiresAt = new Date(endsAt).getTime()\n if (!Number.isFinite(expiresAt)) return '你正在使用新用户 Pro 试用,可使用 Cloud Agent、Arena 和 Studio。'\n const remainingMs = expiresAt - Date.now()\n if (remainingMs <= 0) return 'Pro 试用已结束,续费后可继续使用 Cloud Agent、Arena 和 Studio。'\n const days = Math.ceil(remainingMs / 86_400_000)\n return `新用户 Pro 试用剩余 ${days} 天。试用结束后将自动回到 Free,续费后可继续使用 Cloud Agent、Arena 和 Studio。`\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : {}\n\n const model = String(\n cfg.model\n || meta.model\n || meta.current_model\n || meta.currentModel\n || meta.model_id\n || meta.modelId\n || '',\n ).trim()\n const effortRaw = String(\n cfg.reasoning_effort\n || cfg.reasoningEffort\n || meta.reasoning_effort\n || meta.reasoningEffort\n || meta.thinking_mode\n || meta.thinkingMode\n || '',\n ).trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const { data: personalKbRaw, mutate: mutatePersonalKb } = useSWR(\n session?.accessToken ? ['personal-kb-root', session.accessToken] : null,\n async () => {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!resp.ok) return null\n return resp.json()\n },\n { revalidateOnFocus: false, dedupingInterval: 60000 }\n )\n const personalKbTaskId = useMemo(() => {\n const raw = personalKbRaw as Record | null | undefined\n return raw?.id ? String(raw.id) : undefined\n }, [personalKbRaw])\n\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n mutatePersonalKb(kb, false)\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading, mutatePersonalKb])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const streamEvent = parseMessageStreamEvent(rawEvent)\n if (streamEvent) {\n setAllMessages((prev) => applyMessageStreamEvent(prev, streamEvent, {\n topicId: selectedTopicId || undefined,\n displayName: (senderId) => agentNameMap[senderId] || senderId,\n }))\n if (streamEvent.topic_id === selectedTopicId && streamEvent.sender_id) {\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, streamEvent.topic_id, streamEvent.sender_id, new Date().toISOString()))\n }\n return\n }\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const streamId = streamIdFromMessageRecord(msg.message)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n stream_id: streamId || undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n const withoutStream = removeStreamPlaceholderForFinalMessage(prev, msg.message)\n if (withoutStream.some((m) => m.message_id === incoming.message_id)) return withoutStream\n return [...withoutStream, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n const clearRecentTopicUnread = useCallback((topicId: string) => {\n void mutateRecentTopics((current: unknown) => {\n const clearItem = (item: unknown) => {\n if (!item || typeof item !== 'object') return item\n const row = item as Record\n const id = String(row.topic_id ?? row.id ?? '')\n return id === topicId ? { ...row, unread_count: 0 } : item\n }\n\n if (Array.isArray(current)) return current.map(clearItem)\n if (current && typeof current === 'object') {\n const payload = current as Record\n if (Array.isArray(payload.items)) {\n return { ...payload, items: payload.items.map(clearItem) }\n }\n }\n return current\n }, false)\n }, [mutateRecentTopics])\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n void mutateRecentTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .filter((row) => !shouldHideFeedTopic(row as Record))\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n const selectedTopicKnowledgeTargetAgentId = useMemo(() => (\n selectedTopic?.runner_agent_id || topicActorAgentId || selectedAgentId\n ), [selectedAgentId, selectedTopic?.runner_agent_id, topicActorAgentId])\n const selectedTopicKnowledgeContextType = selectedTopicTaskId ? 'task' : 'chat'\n const canUseKnowledgeMode = Boolean(\n personalKbTaskId &&\n selectedTopic &&\n selectedTopicKnowledgeTargetAgentId\n )\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const proTrialActive = billingPlan === 'pro' && Boolean(billingRaw?.entitlement?.is_trial)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n target_agent_id: aid,\n content: '[system:p2p_init]',\n content_type: 'text',\n semantic_type: 'system',\n metadata: { cloud_no_auto_wake: true },\n }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n const metadata: Record = {}\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n if (options?.commandFamily) metadata.command_family = options.commandFamily\n if (options?.skillId) metadata.skill_id = options.skillId\n }\n if (options?.kbMode && options.kbTaskId) {\n metadata.kb_mode = true\n metadata.kb_scope = options.kbScope || 'personal'\n metadata.kb_task_id = options.kbTaskId\n metadata.kb_context_type = options.kbContextType || (isTask ? 'task' : 'chat')\n metadata.kb_target_agent_id = options.kbTargetAgentId || agentIdForSend\n metadata.kb_query = options.kbQuery || content\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n clearRecentTopicUnread(topicId)\n }, [clearRecentTopicUnread, setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {proTrialActive && (\n
\n
\n {formatProTrialText(billingRaw?.entitlement?.ends_at)}\n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-emerald-300 bg-white px-3 py-1 text-[11px] font-bold text-emerald-800 shadow-sm transition hover:bg-emerald-100 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-100 dark:hover:bg-emerald-400/20\"\n >\n 转为正式 Pro\n \n
\n
\n )}\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n enableCameraCapture\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n canUseKnowledgeMode={canUseKnowledgeMode}\n knowledgeTaskId={personalKbTaskId}\n knowledgeTargetAgentId={selectedTopicKnowledgeTargetAgentId}\n knowledgeContextType={selectedTopicKnowledgeContextType}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "ace11f1d341baa72cefdcfbf1cefb4df891ff83b": {"t": 1785773537.188357, "n": 134392, "h": "6b96ebed47c2865c", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n applyMessageStreamEvent,\n parseMessageStreamEvent,\n removeStreamPlaceholderForFinalMessage,\n streamIdFromMessageRecord,\n} from '@/lib/message-stream'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n ends_at?: string | null\n is_trial?: boolean\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\nfunction formatProTrialText(endsAt?: string | null): string {\n if (!endsAt) return '你正在使用新用户 Pro 试用,可使用 Cloud Agent、Arena 和 Studio。'\n const expiresAt = new Date(endsAt).getTime()\n if (!Number.isFinite(expiresAt)) return '你正在使用新用户 Pro 试用,可使用 Cloud Agent、Arena 和 Studio。'\n const remainingMs = expiresAt - Date.now()\n if (remainingMs <= 0) return 'Pro 试用已结束,续费后可继续使用 Cloud Agent、Arena 和 Studio。'\n const days = Math.ceil(remainingMs / 86_400_000)\n return `新用户 Pro 试用剩余 ${days} 天。试用结束后将自动回到 Free,续费后可继续使用 Cloud Agent、Arena 和 Studio。`\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : {}\n\n const model = String(\n cfg.model\n || meta.model\n || meta.current_model\n || meta.currentModel\n || meta.model_id\n || meta.modelId\n || '',\n ).trim()\n const effortRaw = String(\n cfg.reasoning_effort\n || cfg.reasoningEffort\n || meta.reasoning_effort\n || meta.reasoningEffort\n || meta.thinking_mode\n || meta.thinkingMode\n || '',\n ).trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const { data: personalKbRaw, mutate: mutatePersonalKb } = useSWR(\n session?.accessToken ? ['personal-kb-root', session.accessToken] : null,\n async () => {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!resp.ok) return null\n return resp.json()\n },\n { revalidateOnFocus: false, dedupingInterval: 60000 }\n )\n const personalKbTaskId = useMemo(() => {\n const raw = personalKbRaw as Record | null | undefined\n return raw?.id ? String(raw.id) : undefined\n }, [personalKbRaw])\n\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n mutatePersonalKb(kb, false)\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading, mutatePersonalKb])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const streamEvent = parseMessageStreamEvent(rawEvent)\n if (streamEvent) {\n setAllMessages((prev) => applyMessageStreamEvent(prev, streamEvent, {\n topicId: selectedTopicId || undefined,\n displayName: (senderId) => agentNameMap[senderId] || senderId,\n }))\n if (streamEvent.topic_id === selectedTopicId && streamEvent.sender_id) {\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, streamEvent.topic_id, streamEvent.sender_id, new Date().toISOString()))\n }\n return\n }\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const streamId = streamIdFromMessageRecord(msg.message)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n stream_id: streamId || undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n const withoutStream = removeStreamPlaceholderForFinalMessage(prev, msg.message)\n if (withoutStream.some((m) => m.message_id === incoming.message_id)) return withoutStream\n return [...withoutStream, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .filter((row) => !shouldHideFeedTopic(row as Record))\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n const selectedTopicKnowledgeTargetAgentId = useMemo(() => (\n selectedTopic?.runner_agent_id || topicActorAgentId || selectedAgentId\n ), [selectedAgentId, selectedTopic?.runner_agent_id, topicActorAgentId])\n const selectedTopicKnowledgeContextType = selectedTopicTaskId ? 'task' : 'chat'\n const canUseKnowledgeMode = Boolean(\n personalKbTaskId &&\n selectedTopic &&\n selectedTopicKnowledgeTargetAgentId\n )\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const proTrialActive = billingPlan === 'pro' && Boolean(billingRaw?.entitlement?.is_trial)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n target_agent_id: aid,\n content: '[system:p2p_init]',\n content_type: 'text',\n semantic_type: 'system',\n metadata: { cloud_no_auto_wake: true },\n }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n const metadata: Record = {}\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n if (options?.commandFamily) metadata.command_family = options.commandFamily\n if (options?.skillId) metadata.skill_id = options.skillId\n }\n if (options?.kbMode && options.kbTaskId) {\n metadata.kb_mode = true\n metadata.kb_scope = options.kbScope || 'personal'\n metadata.kb_task_id = options.kbTaskId\n metadata.kb_context_type = options.kbContextType || (isTask ? 'task' : 'chat')\n metadata.kb_target_agent_id = options.kbTargetAgentId || agentIdForSend\n metadata.kb_query = options.kbQuery || content\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {proTrialActive && (\n
\n
\n {formatProTrialText(billingRaw?.entitlement?.ends_at)}\n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-emerald-300 bg-white px-3 py-1 text-[11px] font-bold text-emerald-800 shadow-sm transition hover:bg-emerald-100 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-100 dark:hover:bg-emerald-400/20\"\n >\n 转为正式 Pro\n \n
\n
\n )}\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n enableCameraCapture\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n canUseKnowledgeMode={canUseKnowledgeMode}\n knowledgeTaskId={personalKbTaskId}\n knowledgeTargetAgentId={selectedTopicKnowledgeTargetAgentId}\n knowledgeContextType={selectedTopicKnowledgeContextType}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "fc36dae866d2ad90d18c06abfca27ba7242d587c": {"t": 1785773539.003736, "n": 134246, "h": "699be08dd9e195fe", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n applyMessageStreamEvent,\n parseMessageStreamEvent,\n removeStreamPlaceholderForFinalMessage,\n streamIdFromMessageRecord,\n} from '@/lib/message-stream'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n ends_at?: string | null\n is_trial?: boolean\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\nfunction formatProTrialText(endsAt?: string | null): string {\n if (!endsAt) return '你正在使用新用户 Pro 试用,可使用 Cloud Agent、Arena 和 Studio。'\n const expiresAt = new Date(endsAt).getTime()\n if (!Number.isFinite(expiresAt)) return '你正在使用新用户 Pro 试用,可使用 Cloud Agent、Arena 和 Studio。'\n const remainingMs = expiresAt - Date.now()\n if (remainingMs <= 0) return 'Pro 试用已结束,续费后可继续使用 Cloud Agent、Arena 和 Studio。'\n const days = Math.ceil(remainingMs / 86_400_000)\n return `新用户 Pro 试用剩余 ${days} 天。试用结束后将自动回到 Free,续费后可继续使用 Cloud Agent、Arena 和 Studio。`\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : {}\n\n const model = String(\n cfg.model\n || meta.model\n || meta.current_model\n || meta.currentModel\n || meta.model_id\n || meta.modelId\n || '',\n ).trim()\n const effortRaw = String(\n cfg.reasoning_effort\n || cfg.reasoningEffort\n || meta.reasoning_effort\n || meta.reasoningEffort\n || meta.thinking_mode\n || meta.thinkingMode\n || '',\n ).trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const { data: personalKbRaw, mutate: mutatePersonalKb } = useSWR(\n session?.accessToken ? ['personal-kb-root', session.accessToken] : null,\n async () => {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!resp.ok) return null\n return resp.json()\n },\n { revalidateOnFocus: false, dedupingInterval: 60000 }\n )\n const personalKbTaskId = useMemo(() => {\n const raw = personalKbRaw as Record | null | undefined\n return raw?.id ? String(raw.id) : undefined\n }, [personalKbRaw])\n\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n mutatePersonalKb(kb, false)\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading, mutatePersonalKb])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const streamEvent = parseMessageStreamEvent(rawEvent)\n if (streamEvent) {\n setAllMessages((prev) => applyMessageStreamEvent(prev, streamEvent, {\n topicId: selectedTopicId || undefined,\n displayName: (senderId) => agentNameMap[senderId] || senderId,\n }))\n if (streamEvent.topic_id === selectedTopicId && streamEvent.sender_id) {\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, streamEvent.topic_id, streamEvent.sender_id, new Date().toISOString()))\n }\n return\n }\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const streamId = streamIdFromMessageRecord(msg.message)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n stream_id: streamId || undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n const withoutStream = removeStreamPlaceholderForFinalMessage(prev, msg.message)\n if (withoutStream.some((m) => m.message_id === incoming.message_id)) return withoutStream\n return [...withoutStream, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .filter((row) => !shouldHideFeedTopic(row as Record))\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n const selectedTopicKnowledgeTargetAgentId = useMemo(() => (\n selectedTopic?.runner_agent_id || topicActorAgentId || selectedAgentId\n ), [selectedAgentId, selectedTopic?.runner_agent_id, topicActorAgentId])\n const selectedTopicKnowledgeContextType = selectedTopicTaskId ? 'task' : 'chat'\n const canUseKnowledgeMode = Boolean(\n personalKbTaskId &&\n selectedTopic &&\n selectedTopicKnowledgeTargetAgentId\n )\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const proTrialActive = billingPlan === 'pro' && Boolean(billingRaw?.entitlement?.is_trial)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n target_agent_id: aid,\n content: '[system:p2p_init]',\n content_type: 'text',\n semantic_type: 'system',\n metadata: { cloud_no_auto_wake: true },\n }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n const metadata: Record = {}\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n if (options?.kbMode && options.kbTaskId) {\n metadata.kb_mode = true\n metadata.kb_scope = options.kbScope || 'personal'\n metadata.kb_task_id = options.kbTaskId\n metadata.kb_context_type = options.kbContextType || (isTask ? 'task' : 'chat')\n metadata.kb_target_agent_id = options.kbTargetAgentId || agentIdForSend\n metadata.kb_query = options.kbQuery || content\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {proTrialActive && (\n
\n
\n {formatProTrialText(billingRaw?.entitlement?.ends_at)}\n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-emerald-300 bg-white px-3 py-1 text-[11px] font-bold text-emerald-800 shadow-sm transition hover:bg-emerald-100 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-100 dark:hover:bg-emerald-400/20\"\n >\n 转为正式 Pro\n \n
\n
\n )}\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n enableCameraCapture\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n canUseKnowledgeMode={canUseKnowledgeMode}\n knowledgeTaskId={personalKbTaskId}\n knowledgeTargetAgentId={selectedTopicKnowledgeTargetAgentId}\n knowledgeContextType={selectedTopicKnowledgeContextType}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "9823903e32d94c072fc29d831e50e1eced8488dc": {"t": 1785773541.9308014, "n": 134147, "h": "3d219b2efee9f8ef", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n applyMessageStreamEvent,\n parseMessageStreamEvent,\n removeStreamPlaceholderForFinalMessage,\n streamIdFromMessageRecord,\n} from '@/lib/message-stream'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n ends_at?: string | null\n is_trial?: boolean\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\nfunction formatProTrialText(endsAt?: string | null): string {\n if (!endsAt) return '你正在使用新用户 Pro 试用,可使用 Cloud Agent、Arena 和 Studio。'\n const expiresAt = new Date(endsAt).getTime()\n if (!Number.isFinite(expiresAt)) return '你正在使用新用户 Pro 试用,可使用 Cloud Agent、Arena 和 Studio。'\n const remainingMs = expiresAt - Date.now()\n if (remainingMs <= 0) return 'Pro 试用已结束,续费后可继续使用 Cloud Agent、Arena 和 Studio。'\n const days = Math.ceil(remainingMs / 86_400_000)\n return `新用户 Pro 试用剩余 ${days} 天。试用结束后将自动回到 Free,续费后可继续使用 Cloud Agent、Arena 和 Studio。`\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : {}\n\n const model = String(\n cfg.model\n || meta.model\n || meta.current_model\n || meta.currentModel\n || meta.model_id\n || meta.modelId\n || '',\n ).trim()\n const effortRaw = String(\n cfg.reasoning_effort\n || cfg.reasoningEffort\n || meta.reasoning_effort\n || meta.reasoningEffort\n || meta.thinking_mode\n || meta.thinkingMode\n || '',\n ).trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const { data: personalKbRaw, mutate: mutatePersonalKb } = useSWR(\n session?.accessToken ? ['personal-kb-root', session.accessToken] : null,\n async () => {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!resp.ok) return null\n return resp.json()\n },\n { revalidateOnFocus: false, dedupingInterval: 60000 }\n )\n const personalKbTaskId = useMemo(() => {\n const raw = personalKbRaw as Record | null | undefined\n return raw?.id ? String(raw.id) : undefined\n }, [personalKbRaw])\n\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n mutatePersonalKb(kb, false)\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading, mutatePersonalKb])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const streamEvent = parseMessageStreamEvent(rawEvent)\n if (streamEvent) {\n setAllMessages((prev) => applyMessageStreamEvent(prev, streamEvent, {\n topicId: selectedTopicId || undefined,\n displayName: (senderId) => agentNameMap[senderId] || senderId,\n }))\n if (streamEvent.topic_id === selectedTopicId && streamEvent.sender_id) {\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, streamEvent.topic_id, streamEvent.sender_id, new Date().toISOString()))\n }\n return\n }\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const streamId = streamIdFromMessageRecord(msg.message)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n stream_id: streamId || undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n const withoutStream = removeStreamPlaceholderForFinalMessage(prev, msg.message)\n if (withoutStream.some((m) => m.message_id === incoming.message_id)) return withoutStream\n return [...withoutStream, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .filter((row) => !shouldHideFeedTopic(row as Record))\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n const selectedTopicKnowledgeTargetAgentId = useMemo(() => (\n selectedTopic?.runner_agent_id || topicActorAgentId || selectedAgentId\n ), [selectedAgentId, selectedTopic?.runner_agent_id, topicActorAgentId])\n const selectedTopicKnowledgeContextType = selectedTopicTaskId ? 'task' : 'chat'\n const canUseKnowledgeMode = Boolean(\n personalKbTaskId &&\n selectedTopic &&\n selectedTopicKnowledgeTargetAgentId\n )\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const proTrialActive = billingPlan === 'pro' && Boolean(billingRaw?.entitlement?.is_trial)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n const metadata: Record = {}\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n if (options?.kbMode && options.kbTaskId) {\n metadata.kb_mode = true\n metadata.kb_scope = options.kbScope || 'personal'\n metadata.kb_task_id = options.kbTaskId\n metadata.kb_context_type = options.kbContextType || (isTask ? 'task' : 'chat')\n metadata.kb_target_agent_id = options.kbTargetAgentId || agentIdForSend\n metadata.kb_query = options.kbQuery || content\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {proTrialActive && (\n
\n
\n {formatProTrialText(billingRaw?.entitlement?.ends_at)}\n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-emerald-300 bg-white px-3 py-1 text-[11px] font-bold text-emerald-800 shadow-sm transition hover:bg-emerald-100 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-100 dark:hover:bg-emerald-400/20\"\n >\n 转为正式 Pro\n \n
\n
\n )}\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n enableCameraCapture\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n canUseKnowledgeMode={canUseKnowledgeMode}\n knowledgeTaskId={personalKbTaskId}\n knowledgeTargetAgentId={selectedTopicKnowledgeTargetAgentId}\n knowledgeContextType={selectedTopicKnowledgeContextType}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "4bbf6f8ca1357133f03b95731aebe2d4764aa854": {"t": 1785773545.0563073, "n": 132537, "h": "7df2f2f49017d348", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n applyMessageStreamEvent,\n parseMessageStreamEvent,\n removeStreamPlaceholderForFinalMessage,\n streamIdFromMessageRecord,\n} from '@/lib/message-stream'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : {}\n\n const model = String(\n cfg.model\n || meta.model\n || meta.current_model\n || meta.currentModel\n || meta.model_id\n || meta.modelId\n || '',\n ).trim()\n const effortRaw = String(\n cfg.reasoning_effort\n || cfg.reasoningEffort\n || meta.reasoning_effort\n || meta.reasoningEffort\n || meta.thinking_mode\n || meta.thinkingMode\n || '',\n ).trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const { data: personalKbRaw, mutate: mutatePersonalKb } = useSWR(\n session?.accessToken ? ['personal-kb-root', session.accessToken] : null,\n async () => {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!resp.ok) return null\n return resp.json()\n },\n { revalidateOnFocus: false, dedupingInterval: 60000 }\n )\n const personalKbTaskId = useMemo(() => {\n const raw = personalKbRaw as Record | null | undefined\n return raw?.id ? String(raw.id) : undefined\n }, [personalKbRaw])\n\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n mutatePersonalKb(kb, false)\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading, mutatePersonalKb])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const streamEvent = parseMessageStreamEvent(rawEvent)\n if (streamEvent) {\n setAllMessages((prev) => applyMessageStreamEvent(prev, streamEvent, {\n topicId: selectedTopicId || undefined,\n displayName: (senderId) => agentNameMap[senderId] || senderId,\n }))\n if (streamEvent.topic_id === selectedTopicId && streamEvent.sender_id) {\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, streamEvent.topic_id, streamEvent.sender_id, new Date().toISOString()))\n }\n return\n }\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const streamId = streamIdFromMessageRecord(msg.message)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n stream_id: streamId || undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n const withoutStream = removeStreamPlaceholderForFinalMessage(prev, msg.message)\n if (withoutStream.some((m) => m.message_id === incoming.message_id)) return withoutStream\n return [...withoutStream, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .filter((row) => !shouldHideFeedTopic(row as Record))\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n const selectedTopicKnowledgeTargetAgentId = useMemo(() => (\n selectedTopic?.runner_agent_id || topicActorAgentId || selectedAgentId\n ), [selectedAgentId, selectedTopic?.runner_agent_id, topicActorAgentId])\n const selectedTopicKnowledgeContextType = selectedTopicTaskId ? 'task' : 'chat'\n const canUseKnowledgeMode = Boolean(\n personalKbTaskId &&\n selectedTopic &&\n selectedTopicKnowledgeTargetAgentId\n )\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n const metadata: Record = {}\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n if (options?.kbMode && options.kbTaskId) {\n metadata.kb_mode = true\n metadata.kb_scope = options.kbScope || 'personal'\n metadata.kb_task_id = options.kbTaskId\n metadata.kb_context_type = options.kbContextType || (isTask ? 'task' : 'chat')\n metadata.kb_target_agent_id = options.kbTargetAgentId || agentIdForSend\n metadata.kb_query = options.kbQuery || content\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n enableCameraCapture\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n canUseKnowledgeMode={canUseKnowledgeMode}\n knowledgeTaskId={personalKbTaskId}\n knowledgeTargetAgentId={selectedTopicKnowledgeTargetAgentId}\n knowledgeContextType={selectedTopicKnowledgeContextType}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "69c45b13c760520996b2b0b7235c94e9746a93c3": {"t": 1785773547.2303574, "n": 132460, "h": "bf338d8b7118451a", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n applyMessageStreamEvent,\n parseMessageStreamEvent,\n removeStreamPlaceholderForFinalMessage,\n streamIdFromMessageRecord,\n} from '@/lib/message-stream'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : {}\n\n const model = String(\n cfg.model\n || meta.model\n || meta.current_model\n || meta.currentModel\n || meta.model_id\n || meta.modelId\n || '',\n ).trim()\n const effortRaw = String(\n cfg.reasoning_effort\n || cfg.reasoningEffort\n || meta.reasoning_effort\n || meta.reasoningEffort\n || meta.thinking_mode\n || meta.thinkingMode\n || '',\n ).trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const { data: personalKbRaw, mutate: mutatePersonalKb } = useSWR(\n session?.accessToken ? ['personal-kb-root', session.accessToken] : null,\n async () => {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!resp.ok) return null\n return resp.json()\n },\n { revalidateOnFocus: false, dedupingInterval: 60000 }\n )\n const personalKbTaskId = useMemo(() => {\n const raw = personalKbRaw as Record | null | undefined\n return raw?.id ? String(raw.id) : undefined\n }, [personalKbRaw])\n\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n mutatePersonalKb(kb, false)\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading, mutatePersonalKb])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const streamEvent = parseMessageStreamEvent(rawEvent)\n if (streamEvent) {\n setAllMessages((prev) => applyMessageStreamEvent(prev, streamEvent, {\n topicId: selectedTopicId || undefined,\n displayName: (senderId) => agentNameMap[senderId] || senderId,\n }))\n if (streamEvent.topic_id === selectedTopicId && streamEvent.sender_id) {\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, streamEvent.topic_id, streamEvent.sender_id, new Date().toISOString()))\n }\n return\n }\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const streamId = streamIdFromMessageRecord(msg.message)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n stream_id: streamId || undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n const withoutStream = removeStreamPlaceholderForFinalMessage(prev, msg.message)\n if (withoutStream.some((m) => m.message_id === incoming.message_id)) return withoutStream\n return [...withoutStream, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n const selectedTopicKnowledgeTargetAgentId = useMemo(() => (\n selectedTopic?.runner_agent_id || topicActorAgentId || selectedAgentId\n ), [selectedAgentId, selectedTopic?.runner_agent_id, topicActorAgentId])\n const selectedTopicKnowledgeContextType = selectedTopicTaskId ? 'task' : 'chat'\n const canUseKnowledgeMode = Boolean(\n personalKbTaskId &&\n selectedTopic &&\n selectedTopicKnowledgeTargetAgentId\n )\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n const metadata: Record = {}\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n if (options?.kbMode && options.kbTaskId) {\n metadata.kb_mode = true\n metadata.kb_scope = options.kbScope || 'personal'\n metadata.kb_task_id = options.kbTaskId\n metadata.kb_context_type = options.kbContextType || (isTask ? 'task' : 'chat')\n metadata.kb_target_agent_id = options.kbTargetAgentId || agentIdForSend\n metadata.kb_query = options.kbQuery || content\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n enableCameraCapture\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n canUseKnowledgeMode={canUseKnowledgeMode}\n knowledgeTaskId={personalKbTaskId}\n knowledgeTargetAgentId={selectedTopicKnowledgeTargetAgentId}\n knowledgeContextType={selectedTopicKnowledgeContextType}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "837745975fde768ba9d911d9d8eaa565d3647db3": {"t": 1785773549.4415848, "n": 132424, "h": "20bebc9c3220343f", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n applyMessageStreamEvent,\n parseMessageStreamEvent,\n removeStreamPlaceholderForFinalMessage,\n streamIdFromMessageRecord,\n} from '@/lib/message-stream'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : {}\n\n const model = String(\n cfg.model\n || meta.model\n || meta.current_model\n || meta.currentModel\n || meta.model_id\n || meta.modelId\n || '',\n ).trim()\n const effortRaw = String(\n cfg.reasoning_effort\n || cfg.reasoningEffort\n || meta.reasoning_effort\n || meta.reasoningEffort\n || meta.thinking_mode\n || meta.thinkingMode\n || '',\n ).trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const { data: personalKbRaw, mutate: mutatePersonalKb } = useSWR(\n session?.accessToken ? ['personal-kb-root', session.accessToken] : null,\n async () => {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!resp.ok) return null\n return resp.json()\n },\n { revalidateOnFocus: false, dedupingInterval: 60000 }\n )\n const personalKbTaskId = useMemo(() => {\n const raw = personalKbRaw as Record | null | undefined\n return raw?.id ? String(raw.id) : undefined\n }, [personalKbRaw])\n\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n mutatePersonalKb(kb, false)\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading, mutatePersonalKb])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const streamEvent = parseMessageStreamEvent(rawEvent)\n if (streamEvent) {\n setAllMessages((prev) => applyMessageStreamEvent(prev, streamEvent, {\n topicId: selectedTopicId || undefined,\n displayName: (senderId) => agentNameMap[senderId] || senderId,\n }))\n if (streamEvent.topic_id === selectedTopicId && streamEvent.sender_id) {\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, streamEvent.topic_id, streamEvent.sender_id, new Date().toISOString()))\n }\n return\n }\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const streamId = streamIdFromMessageRecord(msg.message)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n stream_id: streamId || undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n const withoutStream = removeStreamPlaceholderForFinalMessage(prev, msg.message)\n if (withoutStream.some((m) => m.message_id === incoming.message_id)) return withoutStream\n return [...withoutStream, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n const selectedTopicKnowledgeTargetAgentId = useMemo(() => (\n selectedTopic?.runner_agent_id || topicActorAgentId || selectedAgentId\n ), [selectedAgentId, selectedTopic?.runner_agent_id, topicActorAgentId])\n const selectedTopicKnowledgeContextType = selectedTopicTaskId ? 'task' : 'chat'\n const canUseKnowledgeMode = Boolean(\n personalKbTaskId &&\n selectedTopic &&\n selectedTopicKnowledgeTargetAgentId\n )\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n const metadata: Record = {}\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n if (options?.kbMode && options.kbTaskId) {\n metadata.kb_mode = true\n metadata.kb_scope = options.kbScope || 'personal'\n metadata.kb_task_id = options.kbTaskId\n metadata.kb_context_type = options.kbContextType || (isTask ? 'task' : 'chat')\n metadata.kb_target_agent_id = options.kbTargetAgentId || agentIdForSend\n metadata.kb_query = options.kbQuery || content\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n canUseKnowledgeMode={canUseKnowledgeMode}\n knowledgeTaskId={personalKbTaskId}\n knowledgeTargetAgentId={selectedTopicKnowledgeTargetAgentId}\n knowledgeContextType={selectedTopicKnowledgeContextType}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "5d5343a12fdf617696e6fcdbe1fffd0f7926b8d8": {"t": 1785773552.2083375, "n": 132418, "h": "3284325db8b4fcf3", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n applyMessageStreamEvent,\n parseMessageStreamEvent,\n removeStreamPlaceholderForFinalMessage,\n streamIdFromMessageRecord,\n} from '@/lib/message-stream'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : {}\n\n const model = String(\n cfg.model\n || meta.model\n || meta.current_model\n || meta.currentModel\n || meta.model_id\n || meta.modelId\n || '',\n ).trim()\n const effortRaw = String(\n cfg.reasoning_effort\n || cfg.reasoningEffort\n || meta.reasoning_effort\n || meta.reasoningEffort\n || meta.thinking_mode\n || meta.thinkingMode\n || '',\n ).trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const { data: personalKbRaw, mutate: mutatePersonalKb } = useSWR(\n session?.accessToken ? ['personal-kb-root', session.accessToken] : null,\n async () => {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!resp.ok) return null\n return resp.json()\n },\n { revalidateOnFocus: false, dedupingInterval: 60000 }\n )\n const personalKbTaskId = useMemo(() => {\n const raw = personalKbRaw as Record | null | undefined\n return raw?.id ? String(raw.id) : undefined\n }, [personalKbRaw])\n\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n mutatePersonalKb(kb, false)\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading, mutatePersonalKb])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const streamEvent = parseMessageStreamEvent(rawEvent)\n if (streamEvent) {\n setAllMessages((prev) => applyMessageStreamEvent(prev, streamEvent, {\n topicId: selectedTopicId || undefined,\n displayName: (senderId) => agentNameMap[senderId] || senderId,\n }))\n if (streamEvent.topic_id === selectedTopicId && streamEvent.sender_id) {\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, streamEvent.topic_id, streamEvent.sender_id, new Date().toISOString()))\n }\n return\n }\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const streamId = streamIdFromMessageRecord(msg.message)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n stream_id: streamId || undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n const withoutStream = removeStreamPlaceholderForFinalMessage(prev, msg.message)\n if (withoutStream.some((m) => m.message_id === incoming.message_id)) return withoutStream\n return [...withoutStream, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n const selectedTopicKnowledgeTargetAgentId = useMemo(() => (\n selectedTopic?.runner_agent_id || topicActorAgentId || selectedAgentId\n ), [selectedAgentId, selectedTopic?.runner_agent_id, topicActorAgentId])\n const selectedTopicKnowledgeContextType = selectedTopicTaskId ? 'task' : 'chat'\n const canUseKnowledgeMode = Boolean(\n personalKbTaskId &&\n selectedTopic &&\n selectedAgentId &&\n (selectedTopicTaskId || selectedTopic.topic_type === 'p2p')\n )\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n const metadata: Record = {}\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n if (options?.kbMode && options.kbTaskId) {\n metadata.kb_mode = true\n metadata.kb_scope = options.kbScope || 'personal'\n metadata.kb_task_id = options.kbTaskId\n metadata.kb_context_type = options.kbContextType || (isTask ? 'task' : 'chat')\n metadata.kb_target_agent_id = options.kbTargetAgentId || agentIdForSend\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n canUseKnowledgeMode={canUseKnowledgeMode}\n knowledgeTaskId={personalKbTaskId}\n knowledgeTargetAgentId={selectedTopicKnowledgeTargetAgentId}\n knowledgeContextType={selectedTopicKnowledgeContextType}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "200b2626b50df475e54282b84aaa982805968f30": {"t": 1785773555.933252, "n": 130661, "h": "a5851979d407c3f4", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n applyMessageStreamEvent,\n parseMessageStreamEvent,\n removeStreamPlaceholderForFinalMessage,\n streamIdFromMessageRecord,\n} from '@/lib/message-stream'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : {}\n\n const model = String(\n cfg.model\n || meta.model\n || meta.current_model\n || meta.currentModel\n || meta.model_id\n || meta.modelId\n || '',\n ).trim()\n const effortRaw = String(\n cfg.reasoning_effort\n || cfg.reasoningEffort\n || meta.reasoning_effort\n || meta.reasoningEffort\n || meta.thinking_mode\n || meta.thinkingMode\n || '',\n ).trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const streamEvent = parseMessageStreamEvent(rawEvent)\n if (streamEvent) {\n setAllMessages((prev) => applyMessageStreamEvent(prev, streamEvent, {\n topicId: selectedTopicId || undefined,\n displayName: (senderId) => agentNameMap[senderId] || senderId,\n }))\n if (streamEvent.topic_id === selectedTopicId && streamEvent.sender_id) {\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, streamEvent.topic_id, streamEvent.sender_id, new Date().toISOString()))\n }\n return\n }\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const streamId = streamIdFromMessageRecord(msg.message)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n stream_id: streamId || undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n const withoutStream = removeStreamPlaceholderForFinalMessage(prev, msg.message)\n if (withoutStream.some((m) => m.message_id === incoming.message_id)) return withoutStream\n return [...withoutStream, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n const metadata: Record = {}\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "c399b7e10b84e25ba515df8dcc8c2aab0fc03ab4": {"t": 1785773557.8228016, "n": 130702, "h": "cc5895ad801e67d2", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n applyMessageStreamEvent,\n parseMessageStreamEvent,\n removeStreamPlaceholderForFinalMessage,\n streamIdFromMessageRecord,\n} from '@/lib/message-stream'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const streamEvent = parseMessageStreamEvent(rawEvent)\n if (streamEvent) {\n setAllMessages((prev) => applyMessageStreamEvent(prev, streamEvent, {\n topicId: selectedTopicId || undefined,\n displayName: (senderId) => agentNameMap[senderId] || senderId,\n }))\n if (streamEvent.topic_id === selectedTopicId && streamEvent.sender_id) {\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, streamEvent.topic_id, streamEvent.sender_id, new Date().toISOString()))\n }\n return\n }\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const streamId = streamIdFromMessageRecord(msg.message)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n stream_id: streamId || undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n const withoutStream = removeStreamPlaceholderForFinalMessage(prev, msg.message)\n if (withoutStream.some((m) => m.message_id === incoming.message_id)) return withoutStream\n return [...withoutStream, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "88be6ec998541175f2f1c7070b91ad14d8c96d05": {"t": 1785773560.5592446, "n": 129764, "h": "85f70e04aa9f3324", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type AgentOperationType, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n onProgress?.(job)\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n onProgress?.(job)\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSubmitAgentOperation={submitAgentOperation}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "b2a4cc30e4b438f082201c7171f0342b4a6421eb": {"t": 1785773562.4231179, "n": 129635, "h": "bf6f783b6bb99ed6", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(resolveWttUploadUrl(signed.upload_url), {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "8aea21e3e23cf2f2fca3ca5039f2c3b5c0abeae8": {"t": 1785773565.2244153, "n": 129620, "h": "29d0a59984889b99", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions, type RecentTopicItem } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n session?.accessToken ? ['my-recent-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n cache: 'no-store',\n })\n if (!response.ok) return { items: [] }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 2000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray((recentTopicsRaw as { items?: unknown[] } | undefined)?.items)\n ? (recentTopicsRaw as { items: unknown[] }).items\n : Array.isArray(recentTopicsRaw)\n ? recentTopicsRaw as unknown[]\n : []\n return items\n .map((item) => (item && typeof item === 'object' ? item as Record : null))\n .filter(Boolean)\n .map((row) => {\n const topicId = String(row?.topic_id || row?.id || '')\n const agentLabels = Array.isArray(row?.agent_labels)\n ? row.agent_labels\n .map((label) => (label && typeof label === 'object' ? label as Record : null))\n .filter(Boolean)\n .map((label) => ({\n agent_id: String(label?.agent_id || ''),\n display_name: String(label?.display_name || label?.agent_id || ''),\n }))\n .filter((label) => label.agent_id)\n : []\n return {\n topic_id: topicId,\n topic_name: String(row?.topic_name || row?.name || topicId),\n name: String(row?.name || row?.topic_name || topicId),\n topic_type: String(row?.topic_type || row?.type || 'discussion').toLowerCase() as RecentTopicItem['topic_type'],\n type: String(row?.type || row?.topic_type || 'discussion').toLowerCase() as RecentTopicItem['type'],\n last_activity_at: String(row?.last_activity_at || row?.created_at || ''),\n last_message_preview: String(row?.last_message_preview || ''),\n last_sender_id: String(row?.last_sender_id || ''),\n primary_agent_id: String(row?.primary_agent_id || ''),\n agent_ids: Array.isArray(row?.agent_ids) ? row.agent_ids.map(String).filter(Boolean) : [],\n member_agent_ids: Array.isArray(row?.member_agent_ids) ? row.member_agent_ids.map(String).filter(Boolean) : [],\n agent_labels: agentLabels,\n unread_count: Number(row?.unread_count || 0),\n }\n })\n .filter((topic) => topic.topic_id)\n }, [recentTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateRecentTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n recentTopics={recentTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "607cc104b9adf5b126c1a5ba178a4ee04e82bc35": {"t": 1785773568.8465042, "n": 126800, "h": "1494947514dc8afb", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) {\n const detail = await response.text().catch(() => '')\n throw new Error(detail || `Failed to load group topics (${response.status})`)\n }\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "785c119a5989ddea07dbbd873b2619e0b5180b6f": {"t": 1785773594.5256777, "n": 126643, "h": "0089645b850ed75e", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n task_title?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String((topic.task_id && topic.task_title) || topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) return []\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id || (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] : '')\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [createdTaskIdsByTopic, selectedTopic?.task_id, selectedTopic?.name, selectedTopicId])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [topicId]: taskId }))\n }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "c0b7e614d7116bf4959ce93c3e15c45f692176c5": {"t": 1785773597.9794955, "n": 126232, "h": "cfc2c6a4c1a508bf", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\nfunction isDefaultTaskTitle(topic?: { name?: string; task_id?: string } | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const line = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .find(Boolean)\n if (!line) return 'New Task'\n return line.length > 36 ? `${line.slice(0, 34)}...` : line\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String(topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) return []\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => {\n const mappedTopic = mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId })\n const optimistic = optimisticTaskTitles[mappedTopic.topic_id]\n if (optimistic && optimistic.expiresAt > Date.now() && isDefaultTaskTitle(mappedTopic)) {\n return { ...mappedTopic, name: optimistic.title }\n }\n return mappedTopic\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId, optimisticTaskTitles])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n let changed = false\n const next: Record = {}\n for (const [topicId, value] of Object.entries(optimisticTaskTitles)) {\n if (value.expiresAt > now) next[topicId] = value\n else changed = true\n }\n if (changed) setOptimisticTaskTitles(next)\n }, [optimisticTaskTitles, subscribedTopicsRaw])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n const taskId = String(real?.id || real?.task_id || '').trim()\n if (taskId) pendingRenameTaskRef.current = { taskId, topicId }\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n if (!sendResp.ok) {\n const data = await sendResp.json().catch(() => null) as { detail?: unknown } | null\n const detail = data?.detail\n const message = typeof detail === 'string'\n ? detail\n : detail && typeof detail === 'object' && 'message' in detail\n ? String((detail as { message?: unknown }).message || '')\n : `发送失败 (${sendResp.status})`\n setTypingByTopic((prev) => {\n const next = { ...prev }\n delete next[topicIdForSend]\n return next\n })\n alert(message)\n return\n }\n // Force topic list refresh so auto-renamed title appears immediately\n if (pendingRenameTaskRef.current?.topicId === topicIdForSend) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n setOptimisticTaskTitles((current) => ({\n ...current,\n [topicIdForSend]: {\n title: optimisticTitle,\n expiresAt: Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS,\n },\n }))\n }\n pendingRenameTaskRef.current = null\n }\n await mutateTopics()\n window.setTimeout(() => void mutateTopics(), 3500)\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "24a8168b8e3fd6eb6864b74443af05d5a95eb6d9": {"t": 1785773600.6425753, "n": 123441, "h": "dc1b8a0220eb8c14", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String(topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) return []\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId }))\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "17a359b84bc7aaa8a0093d22ef70e4d6d440f242": {"t": 1785773604.0346491, "n": 125856, "h": "fa13dc88927be8be", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction shouldHideFeedTopic(topic: Record): boolean {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').trim()\n const creatorAgentId = String(topic.creator_agent_id || topic.creatorAgentId || '').trim()\n const originType = String(topic.origin_type || topic.originType || '').toLowerCase()\n const searchable = `${name}\\n${description}\\n${originType}\\n${creatorAgentId}\\n${collectTopicSearchText(topic)}`.toLowerCase()\n\n // Arena and Square topics have gone through a few naming/metadata revisions.\n // Keep the filter intentionally redundant so older subscribed rows disappear too.\n if (creatorAgentId === 'agent-16a45cf0dd8b') return true\n if (name === 'Arena Coach' || name.startsWith('Arena Coach:')) return true\n if (description.includes('Private Arena Coach chat')) return true\n if (\n searchable.includes('arena') ||\n searchable.includes('challenge_id') ||\n searchable.includes('challenge_slug') ||\n searchable.includes('/arena/')\n ) return true\n\n if (name.startsWith('__SQUARE__/')) return true\n if (name.startsWith('若水广场|') || name.startsWith('若水专文|')) return true\n if (name.startsWith('知乎精选:')) return true\n if (description.startsWith('[若水广场:')) return true\n if (searchable.includes('若水广场') || searchable.includes('__square__')) return true\n if (originType === 'column' || originType === 'human_post' || originType.includes('square')) return true\n\n const squareFlags = [\n 'square',\n 'is_square',\n 'square_post',\n 'square_topic',\n 'squarePost',\n 'squareTopic',\n ]\n if (squareFlags.some((key) => Boolean(topic[key]))) return true\n\n const meta = topic.metadata || topic.msg_metadata || topic.meta\n if (typeof meta === 'string' && meta.toLowerCase().includes('\"square\"')) return true\n if (meta && typeof meta === 'object' && Boolean((meta as Record).square)) return true\n\n return false\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String(topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction collectTopicSearchText(value: unknown, depth = 0): string {\n if (value == null || depth > 3) return ''\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map((item) => collectTopicSearchText(item, depth + 1)).join('\\n')\n if (typeof value === 'object') {\n return Object.entries(value as Record)\n .map(([key, item]) => `${key}:${collectTopicSearchText(item, depth + 1)}`)\n .join('\\n')\n }\n return ''\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) return []\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId }))\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw, mutate: mutateBilling } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 30_000, revalidateOnFocus: true, dedupingInterval: 5_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n void mutateBilling()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "5b2ef61f1ede7b9e881e2c0545e2b85aab4ee7f6": {"t": 1785773605.7014592, "n": 125787, "h": "f1030f4512afa69c", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction shouldHideFeedTopic(topic: Record): boolean {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').trim()\n const creatorAgentId = String(topic.creator_agent_id || topic.creatorAgentId || '').trim()\n const originType = String(topic.origin_type || topic.originType || '').toLowerCase()\n const searchable = `${name}\\n${description}\\n${originType}\\n${creatorAgentId}\\n${collectTopicSearchText(topic)}`.toLowerCase()\n\n // Arena and Square topics have gone through a few naming/metadata revisions.\n // Keep the filter intentionally redundant so older subscribed rows disappear too.\n if (creatorAgentId === 'agent-16a45cf0dd8b') return true\n if (name === 'Arena Coach' || name.startsWith('Arena Coach:')) return true\n if (description.includes('Private Arena Coach chat')) return true\n if (\n searchable.includes('arena') ||\n searchable.includes('challenge_id') ||\n searchable.includes('challenge_slug') ||\n searchable.includes('/arena/')\n ) return true\n\n if (name.startsWith('__SQUARE__/')) return true\n if (name.startsWith('若水广场|') || name.startsWith('若水专文|')) return true\n if (name.startsWith('知乎精选:')) return true\n if (description.startsWith('[若水广场:')) return true\n if (searchable.includes('若水广场') || searchable.includes('__square__')) return true\n if (originType === 'column' || originType === 'human_post' || originType.includes('square')) return true\n\n const squareFlags = [\n 'square',\n 'is_square',\n 'square_post',\n 'square_topic',\n 'squarePost',\n 'squareTopic',\n ]\n if (squareFlags.some((key) => Boolean(topic[key]))) return true\n\n const meta = topic.metadata || topic.msg_metadata || topic.meta\n if (typeof meta === 'string' && meta.toLowerCase().includes('\"square\"')) return true\n if (meta && typeof meta === 'object' && Boolean((meta as Record).square)) return true\n\n return false\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String(topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction collectTopicSearchText(value: unknown, depth = 0): string {\n if (value == null || depth > 3) return ''\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map((item) => collectTopicSearchText(item, depth + 1)).join('\\n')\n if (typeof value === 'object') {\n return Object.entries(value as Record)\n .map(([key, item]) => `${key}:${collectTopicSearchText(item, depth + 1)}`)\n .join('\\n')\n }\n return ''\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const groupTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const updateTopicUnreadCaches = useCallback((topicId: string, updater: (topic: Record) => Record) => {\n for (const ref of [subscribedTopicsRef, groupTopicsRef]) {\n const { raw, mutate: mutateList } = ref.current\n if (!Array.isArray(raw)) continue\n mutateList(\n raw.map((item) => {\n const record = item as Record\n const id = String(record.id ?? record.topic_id ?? '')\n if (id !== topicId) return item\n return updater(record)\n }),\n false,\n )\n }\n }, [])\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const now = new Date().toISOString()\n updateTopicUnreadCaches(incomingTopicId, (rec) => {\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay, updateTopicUnreadCaches],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) return []\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n useEffect(() => {\n groupTopicsRef.current = { raw: groupTopicsRaw ?? null, mutate: mutateGroupTopics }\n }, [groupTopicsRaw, mutateGroupTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n void mutateGroupTopics()\n }, [selectedTopicId, feedRaw, mutateGroupTopics, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId }))\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 5 * 60_000, revalidateOnFocus: false, dedupingInterval: 60_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n updateTopicUnreadCaches(topicId, (row) => ({ ...row, unread_count: 0 }))\n }, [setSelectedTopicId, updateTopicUnreadCaches])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "cbd9ea3602c10a22ab17699aa034e15b055402f0": {"t": 1785773607.4207175, "n": 125348, "h": "b4438ff65d02baee", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n name?: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction shouldHideFeedTopic(topic: Record): boolean {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').trim()\n const creatorAgentId = String(topic.creator_agent_id || topic.creatorAgentId || '').trim()\n const originType = String(topic.origin_type || topic.originType || '').toLowerCase()\n const searchable = `${name}\\n${description}\\n${originType}\\n${creatorAgentId}\\n${collectTopicSearchText(topic)}`.toLowerCase()\n\n // Arena and Square topics have gone through a few naming/metadata revisions.\n // Keep the filter intentionally redundant so older subscribed rows disappear too.\n if (creatorAgentId === 'agent-16a45cf0dd8b') return true\n if (name === 'Arena Coach' || name.startsWith('Arena Coach:')) return true\n if (description.includes('Private Arena Coach chat')) return true\n if (\n searchable.includes('arena') ||\n searchable.includes('challenge_id') ||\n searchable.includes('challenge_slug') ||\n searchable.includes('/arena/')\n ) return true\n\n if (name.startsWith('__SQUARE__/')) return true\n if (name.startsWith('若水广场|') || name.startsWith('若水专文|')) return true\n if (name.startsWith('知乎精选:')) return true\n if (description.startsWith('[若水广场:')) return true\n if (searchable.includes('若水广场') || searchable.includes('__square__')) return true\n if (originType === 'column' || originType === 'human_post' || originType.includes('square')) return true\n\n const squareFlags = [\n 'square',\n 'is_square',\n 'square_post',\n 'square_topic',\n 'squarePost',\n 'squareTopic',\n ]\n if (squareFlags.some((key) => Boolean(topic[key]))) return true\n\n const meta = topic.metadata || topic.msg_metadata || topic.meta\n if (typeof meta === 'string' && meta.toLowerCase().includes('\"square\"')) return true\n if (meta && typeof meta === 'object' && Boolean((meta as Record).square)) return true\n\n return false\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String(topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction collectTopicSearchText(value: unknown, depth = 0): string {\n if (value == null || depth > 3) return ''\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map((item) => collectTopicSearchText(item, depth + 1)).join('\\n')\n if (typeof value === 'object') {\n return Object.entries(value as Record)\n .map(([key, item]) => `${key}:${collectTopicSearchText(item, depth + 1)}`)\n .join('\\n')\n }\n return ''\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const { raw, mutate: mutateSubs } = subscribedTopicsRef.current\n if (raw && Array.isArray(raw)) {\n const now = new Date().toISOString()\n mutateSubs(\n raw.map((t) => {\n const rec = t as Record\n if (rec.id !== incomingTopicId) return t\n\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n }),\n false,\n )\n }\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) return []\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [selectedTopicId, feedRaw, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId }))\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n name: agent.name,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 5 * 60_000, revalidateOnFocus: false, dedupingInterval: 60_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n void mutateTopics((prev: unknown) => {\n if (!Array.isArray(prev)) return prev\n return prev.map((t) => {\n const row = t as Record\n const id = String(row.id ?? row.topic_id ?? '')\n if (id !== topicId) return t\n return { ...row, unread_count: 0 }\n })\n }, false)\n }, [mutateTopics, setSelectedTopicId])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "fb4f58ea067864c68f5f6d4b392fe11c90926194": {"t": 1785773609.240629, "n": 125308, "h": "0488fd70ae02e8fb", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction shouldHideFeedTopic(topic: Record): boolean {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').trim()\n const creatorAgentId = String(topic.creator_agent_id || topic.creatorAgentId || '').trim()\n const originType = String(topic.origin_type || topic.originType || '').toLowerCase()\n const searchable = `${name}\\n${description}\\n${originType}\\n${creatorAgentId}\\n${collectTopicSearchText(topic)}`.toLowerCase()\n\n // Arena and Square topics have gone through a few naming/metadata revisions.\n // Keep the filter intentionally redundant so older subscribed rows disappear too.\n if (creatorAgentId === 'agent-16a45cf0dd8b') return true\n if (name === 'Arena Coach' || name.startsWith('Arena Coach:')) return true\n if (description.includes('Private Arena Coach chat')) return true\n if (\n searchable.includes('arena') ||\n searchable.includes('challenge_id') ||\n searchable.includes('challenge_slug') ||\n searchable.includes('/arena/')\n ) return true\n\n if (name.startsWith('__SQUARE__/')) return true\n if (name.startsWith('若水广场|') || name.startsWith('若水专文|')) return true\n if (name.startsWith('知乎精选:')) return true\n if (description.startsWith('[若水广场:')) return true\n if (searchable.includes('若水广场') || searchable.includes('__square__')) return true\n if (originType === 'column' || originType === 'human_post' || originType.includes('square')) return true\n\n const squareFlags = [\n 'square',\n 'is_square',\n 'square_post',\n 'square_topic',\n 'squarePost',\n 'squareTopic',\n ]\n if (squareFlags.some((key) => Boolean(topic[key]))) return true\n\n const meta = topic.metadata || topic.msg_metadata || topic.meta\n if (typeof meta === 'string' && meta.toLowerCase().includes('\"square\"')) return true\n if (meta && typeof meta === 'object' && Boolean((meta as Record).square)) return true\n\n return false\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String(topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction collectTopicSearchText(value: unknown, depth = 0): string {\n if (value == null || depth > 3) return ''\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map((item) => collectTopicSearchText(item, depth + 1)).join('\\n')\n if (typeof value === 'object') {\n return Object.entries(value as Record)\n .map(([key, item]) => `${key}:${collectTopicSearchText(item, depth + 1)}`)\n .join('\\n')\n }\n return ''\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const { raw, mutate: mutateSubs } = subscribedTopicsRef.current\n if (raw && Array.isArray(raw)) {\n const now = new Date().toISOString()\n mutateSubs(\n raw.map((t) => {\n const rec = t as Record\n if (rec.id !== incomingTopicId) return t\n\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n }),\n false,\n )\n }\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) return []\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [selectedTopicId, feedRaw, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId }))\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(id)\n }\n for (const member of topicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, topicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n const isGroupTopic = ['discussion', 'collaborative'].includes(selectedTopic.topic_type)\n if (!isGroupTopic || selectedTopicTaskHint) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTopic, selectedTopicMemberIdSet, selectedTopicTaskHint])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 5 * 60_000, revalidateOnFocus: false, dedupingInterval: 60_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = topicActorAgentId || selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = agentIdForSend\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: agentIdForSend || undefined,\n userToken: session?.accessToken,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n void mutateTopics((prev: unknown) => {\n if (!Array.isArray(prev)) return prev\n return prev.map((t) => {\n const row = t as Record\n const id = String(row.id ?? row.topic_id ?? '')\n if (id !== topicId) return t\n return { ...row, unread_count: 0 }\n })\n }, false)\n }, [mutateTopics, setSelectedTopicId])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "140e3df2e7e3c752c176648486af7d83091cb2aa": {"t": 1785773612.5830317, "n": 124269, "h": "d5e447c59ae1f0d1", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction isRetryableOperationStatus(status: number): boolean {\n return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504\n}\n\nfunction isRetryableOperationError(error: unknown): boolean {\n if (error instanceof Error) {\n const message = error.message.toLowerCase()\n return message.includes('timed out') || message.includes('failed to fetch') || message.includes('network')\n }\n return false\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction shouldHideFeedTopic(topic: Record): boolean {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').trim()\n const creatorAgentId = String(topic.creator_agent_id || topic.creatorAgentId || '').trim()\n const originType = String(topic.origin_type || topic.originType || '').toLowerCase()\n const searchable = `${name}\\n${description}\\n${originType}\\n${creatorAgentId}\\n${collectTopicSearchText(topic)}`.toLowerCase()\n\n // Arena and Square topics have gone through a few naming/metadata revisions.\n // Keep the filter intentionally redundant so older subscribed rows disappear too.\n if (creatorAgentId === 'agent-16a45cf0dd8b') return true\n if (name === 'Arena Coach' || name.startsWith('Arena Coach:')) return true\n if (description.includes('Private Arena Coach chat')) return true\n if (\n searchable.includes('arena') ||\n searchable.includes('challenge_id') ||\n searchable.includes('challenge_slug') ||\n searchable.includes('/arena/')\n ) return true\n\n if (name.startsWith('__SQUARE__/')) return true\n if (name.startsWith('若水广场|') || name.startsWith('若水专文|')) return true\n if (name.startsWith('知乎精选:')) return true\n if (description.startsWith('[若水广场:')) return true\n if (searchable.includes('若水广场') || searchable.includes('__square__')) return true\n if (originType === 'column' || originType === 'human_post' || originType.includes('square')) return true\n\n const squareFlags = [\n 'square',\n 'is_square',\n 'square_post',\n 'square_topic',\n 'squarePost',\n 'squareTopic',\n ]\n if (squareFlags.some((key) => Boolean(topic[key]))) return true\n\n const meta = topic.metadata || topic.msg_metadata || topic.meta\n if (typeof meta === 'string' && meta.toLowerCase().includes('\"square\"')) return true\n if (meta && typeof meta === 'object' && Boolean((meta as Record).square)) return true\n\n return false\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String(topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction collectTopicSearchText(value: unknown, depth = 0): string {\n if (value == null || depth > 3) return ''\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map((item) => collectTopicSearchText(item, depth + 1)).join('\\n')\n if (typeof value === 'object') {\n return Object.entries(value as Record)\n .map(([key, item]) => `${key}:${collectTopicSearchText(item, depth + 1)}`)\n .join('\\n')\n }\n return ''\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const { raw, mutate: mutateSubs } = subscribedTopicsRef.current\n if (raw && Array.isArray(raw)) {\n const now = new Date().toISOString()\n mutateSubs(\n raw.map((t) => {\n const rec = t as Record\n if (rec.id !== incomingTopicId) return t\n\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n }),\n false,\n )\n }\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) return []\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [selectedTopicId, feedRaw, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId }))\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 5 * 60_000, revalidateOnFocus: false, dedupingInterval: 60_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n let createRes: Response | null = null\n let createData: unknown = null\n let lastCreateError: unknown = null\n const operationBody = JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n })\n\n for (let attempt = 0; attempt < 3; attempt += 1) {\n try {\n const result = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: operationBody,\n })\n createRes = result.response\n createData = result.data\n if (createRes.ok || !isRetryableOperationStatus(createRes.status) || attempt === 2) {\n break\n }\n } catch (error) {\n lastCreateError = error\n if (!isRetryableOperationError(error) || attempt === 2) {\n throw error\n }\n }\n await delay(650 * (attempt + 1))\n }\n\n if (!createRes) {\n if (lastCreateError instanceof Error) throw lastCreateError\n throw new Error('Agent operation request failed')\n }\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = selectedAgentId\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: selectedAgentId || undefined,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n void mutateTopics((prev: unknown) => {\n if (!Array.isArray(prev)) return prev\n return prev.map((t) => {\n const row = t as Record\n const id = String(row.id ?? row.topic_id ?? '')\n if (id !== topicId) return t\n return { ...row, unread_count: 0 }\n })\n }, false)\n }, [mutateTopics, setSelectedTopicId])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "4096667d15929f9923def1d5d626a624d23f4a3e": {"t": 1785773615.0428991, "n": 123064, "h": "fc34c07db730fe11", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction shouldHideFeedTopic(topic: Record): boolean {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').trim()\n const creatorAgentId = String(topic.creator_agent_id || topic.creatorAgentId || '').trim()\n const originType = String(topic.origin_type || topic.originType || '').toLowerCase()\n const searchable = `${name}\\n${description}\\n${originType}\\n${creatorAgentId}\\n${collectTopicSearchText(topic)}`.toLowerCase()\n\n // Arena and Square topics have gone through a few naming/metadata revisions.\n // Keep the filter intentionally redundant so older subscribed rows disappear too.\n if (creatorAgentId === 'agent-16a45cf0dd8b') return true\n if (name === 'Arena Coach' || name.startsWith('Arena Coach:')) return true\n if (description.includes('Private Arena Coach chat')) return true\n if (\n searchable.includes('arena') ||\n searchable.includes('challenge_id') ||\n searchable.includes('challenge_slug') ||\n searchable.includes('/arena/')\n ) return true\n\n if (name.startsWith('__SQUARE__/')) return true\n if (name.startsWith('若水广场|') || name.startsWith('若水专文|')) return true\n if (name.startsWith('知乎精选:')) return true\n if (description.startsWith('[若水广场:')) return true\n if (searchable.includes('若水广场') || searchable.includes('__square__')) return true\n if (originType === 'column' || originType === 'human_post' || originType.includes('square')) return true\n\n const squareFlags = [\n 'square',\n 'is_square',\n 'square_post',\n 'square_topic',\n 'squarePost',\n 'squareTopic',\n ]\n if (squareFlags.some((key) => Boolean(topic[key]))) return true\n\n const meta = topic.metadata || topic.msg_metadata || topic.meta\n if (typeof meta === 'string' && meta.toLowerCase().includes('\"square\"')) return true\n if (meta && typeof meta === 'object' && Boolean((meta as Record).square)) return true\n\n return false\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String(topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction collectTopicSearchText(value: unknown, depth = 0): string {\n if (value == null || depth > 3) return ''\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map((item) => collectTopicSearchText(item, depth + 1)).join('\\n')\n if (typeof value === 'object') {\n return Object.entries(value as Record)\n .map(([key, item]) => `${key}:${collectTopicSearchText(item, depth + 1)}`)\n .join('\\n')\n }\n return ''\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const { raw, mutate: mutateSubs } = subscribedTopicsRef.current\n if (raw && Array.isArray(raw)) {\n const now = new Date().toISOString()\n mutateSubs(\n raw.map((t) => {\n const rec = t as Record\n if (rec.id !== incomingTopicId) return t\n\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n }),\n false,\n )\n }\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) return []\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [selectedTopicId, feedRaw, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId }))\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 5 * 60_000, revalidateOnFocus: false, dedupingInterval: 60_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const billingPlan = String(billingRaw?.entitlement?.plan || '').toLowerCase()\n const billingLoaded = Boolean(billingRaw?.entitlement)\n const cloudAgentNeedsRenewal = billingLoaded && hasCloudAgentRecord && billingPlan !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n const { response: createRes, data: createData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n }),\n })\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = selectedAgentId\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: selectedAgentId || undefined,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n void mutateTopics((prev: unknown) => {\n if (!Array.isArray(prev)) return prev\n return prev.map((t) => {\n const row = t as Record\n const id = String(row.id ?? row.topic_id ?? '')\n if (id !== topicId) return t\n return { ...row, unread_count: 0 }\n })\n }, false)\n }, [mutateTopics, setSelectedTopicId])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "30e71af800cce8877ef9be1e6ecd770c29ef4e26": {"t": 1785773616.655583, "n": 122960, "h": "1d538c91667a3df8", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction shouldHideFeedTopic(topic: Record): boolean {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').trim()\n const creatorAgentId = String(topic.creator_agent_id || topic.creatorAgentId || '').trim()\n const originType = String(topic.origin_type || topic.originType || '').toLowerCase()\n const searchable = `${name}\\n${description}\\n${originType}\\n${creatorAgentId}\\n${collectTopicSearchText(topic)}`.toLowerCase()\n\n // Arena and Square topics have gone through a few naming/metadata revisions.\n // Keep the filter intentionally redundant so older subscribed rows disappear too.\n if (creatorAgentId === 'agent-16a45cf0dd8b') return true\n if (name === 'Arena Coach' || name.startsWith('Arena Coach:')) return true\n if (description.includes('Private Arena Coach chat')) return true\n if (\n searchable.includes('arena') ||\n searchable.includes('challenge_id') ||\n searchable.includes('challenge_slug') ||\n searchable.includes('/arena/')\n ) return true\n\n if (name.startsWith('__SQUARE__/')) return true\n if (name.startsWith('若水广场|') || name.startsWith('若水专文|')) return true\n if (name.startsWith('知乎精选:')) return true\n if (description.startsWith('[若水广场:')) return true\n if (searchable.includes('若水广场') || searchable.includes('__square__')) return true\n if (originType === 'column' || originType === 'human_post' || originType.includes('square')) return true\n\n const squareFlags = [\n 'square',\n 'is_square',\n 'square_post',\n 'square_topic',\n 'squarePost',\n 'squareTopic',\n ]\n if (squareFlags.some((key) => Boolean(topic[key]))) return true\n\n const meta = topic.metadata || topic.msg_metadata || topic.meta\n if (typeof meta === 'string' && meta.toLowerCase().includes('\"square\"')) return true\n if (meta && typeof meta === 'object' && Boolean((meta as Record).square)) return true\n\n return false\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String(topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction isDiscussionGroupTopicItem(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\nfunction collectTopicSearchText(value: unknown, depth = 0): string {\n if (value == null || depth > 3) return ''\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map((item) => collectTopicSearchText(item, depth + 1)).join('\\n')\n if (typeof value === 'object') {\n return Object.entries(value as Record)\n .map(([key, item]) => `${key}:${collectTopicSearchText(item, depth + 1)}`)\n .join('\\n')\n }\n return ''\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const { raw, mutate: mutateSubs } = subscribedTopicsRef.current\n if (raw && Array.isArray(raw)) {\n const now = new Date().toISOString()\n mutateSubs(\n raw.map((t) => {\n const rec = t as Record\n if (rec.id !== incomingTopicId) return t\n\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n }),\n false,\n )\n }\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) return []\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [selectedTopicId, feedRaw, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId }))\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter(isDiscussionGroupTopicItem)\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 5 * 60_000, revalidateOnFocus: false, dedupingInterval: 60_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const cloudAgentNeedsRenewal = hasCloudAgentRecord && String(billingRaw?.entitlement?.plan || 'free').toLowerCase() !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n const { response: createRes, data: createData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n }),\n })\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = selectedAgentId\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: selectedAgentId || undefined,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n void mutateTopics((prev: unknown) => {\n if (!Array.isArray(prev)) return prev\n return prev.map((t) => {\n const row = t as Record\n const id = String(row.id ?? row.topic_id ?? '')\n if (id !== topicId) return t\n return { ...row, unread_count: 0 }\n })\n }, false)\n }, [mutateTopics, setSelectedTopicId])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "b185e9844a9401123c3c85154038dac858782056": {"t": 1785773618.2354598, "n": 122351, "h": "2ee69589157c1ba8", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction shouldHideFeedTopic(topic: Record): boolean {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').trim()\n const creatorAgentId = String(topic.creator_agent_id || topic.creatorAgentId || '').trim()\n const originType = String(topic.origin_type || topic.originType || '').toLowerCase()\n const searchable = `${name}\\n${description}\\n${originType}\\n${creatorAgentId}\\n${collectTopicSearchText(topic)}`.toLowerCase()\n\n // Arena and Square topics have gone through a few naming/metadata revisions.\n // Keep the filter intentionally redundant so older subscribed rows disappear too.\n if (creatorAgentId === 'agent-16a45cf0dd8b') return true\n if (name === 'Arena Coach' || name.startsWith('Arena Coach:')) return true\n if (description.includes('Private Arena Coach chat')) return true\n if (\n searchable.includes('arena') ||\n searchable.includes('challenge_id') ||\n searchable.includes('challenge_slug') ||\n searchable.includes('/arena/')\n ) return true\n\n if (name.startsWith('__SQUARE__/')) return true\n if (name.startsWith('若水广场|') || name.startsWith('若水专文|')) return true\n if (name.startsWith('知乎精选:')) return true\n if (description.startsWith('[若水广场:')) return true\n if (searchable.includes('若水广场') || searchable.includes('__square__')) return true\n if (originType === 'column' || originType === 'human_post' || originType.includes('square')) return true\n\n const squareFlags = [\n 'square',\n 'is_square',\n 'square_post',\n 'square_topic',\n 'squarePost',\n 'squareTopic',\n ]\n if (squareFlags.some((key) => Boolean(topic[key]))) return true\n\n const meta = topic.metadata || topic.msg_metadata || topic.meta\n if (typeof meta === 'string' && meta.toLowerCase().includes('\"square\"')) return true\n if (meta && typeof meta === 'object' && Boolean((meta as Record).square)) return true\n\n return false\n}\n\ntype RawTopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n my_role?: string\n task_id?: string\n runner_agent_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n last_activity_at?: string\n creator_agent_id?: string\n unread_count?: number\n member_agent_ids?: string[]\n}\n\nfunction mapRawTopicToItem(\n topic: RawTopicRecord,\n options?: { selectedAgentId?: string; humanSender?: string; p2pTopicByAgentId?: Record },\n): TopicItem {\n const topicId = String(topic.id || topic.topic_id || '').trim()\n const topicType = String(topic.type || topic.topic_type || 'discussion').toLowerCase() as TopicItem['topic_type']\n const selectedAgentId = options?.selectedAgentId || ''\n const humanSender = options?.humanSender || ''\n const p2pTopicByAgentId = options?.p2pTopicByAgentId || {}\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topicId ||\n (String(topic.name || '').includes(selectedAgentId) && String(topic.name || '').includes(humanSender))\n )\n\n return {\n topic_id: topicId,\n name: String(topic.name || topicId || 'Topic'),\n topic_type: topicType,\n unread_count: Number(topic.unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: Array.isArray(topic.member_agent_ids) ? topic.member_agent_ids.map(String).filter(Boolean) : undefined,\n }\n}\n\nfunction collectTopicSearchText(value: unknown, depth = 0): string {\n if (value == null || depth > 3) return ''\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map((item) => collectTopicSearchText(item, depth + 1)).join('\\n')\n if (typeof value === 'object') {\n return Object.entries(value as Record)\n .map(([key, item]) => `${key}:${collectTopicSearchText(item, depth + 1)}`)\n .join('\\n')\n }\n return ''\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const { raw, mutate: mutateSubs } = subscribedTopicsRef.current\n if (raw && Array.isArray(raw)) {\n const now = new Date().toISOString()\n mutateSubs(\n raw.map((t) => {\n const rec = t as Record\n if (rec.id !== incomingTopicId) return t\n\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n }),\n false,\n )\n }\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n session?.accessToken ? ['my-group-topics', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n if (!response.ok) return []\n return response.json()\n },\n {\n refreshInterval: wsState === 'connected' ? 60000 : 30000,\n revalidateOnFocus: true,\n dedupingInterval: 5000,\n },\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [selectedTopicId, feedRaw, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic, { selectedAgentId, humanSender, p2pTopicByAgentId }))\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const groupTopics = useMemo(() => {\n if (!Array.isArray(groupTopicsRaw)) return []\n const seen = new Set()\n return groupTopicsRaw\n .filter((topic: RawTopicRecord & { origin_type?: string; originType?: string }) => !shouldHideFeedTopic(topic as Record))\n .map((topic: RawTopicRecord) => mapRawTopicToItem(topic))\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopicsRaw])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId) || groupTopics.find((t) => t.topic_id === selectedTopicId)\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (\n selectedTopicId &&\n Array.isArray(subscribedTopicsRaw) &&\n !topics.some(t => t.topic_id === selectedTopicId) &&\n !groupTopics.some(t => t.topic_id === selectedTopicId)\n ) {\n setSelectedTopicId(null)\n }\n }, [groupTopics, topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateGroupTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateGroupTopics, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 5 * 60_000, revalidateOnFocus: false, dedupingInterval: 60_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const cloudAgentNeedsRenewal = hasCloudAgentRecord && String(billingRaw?.entitlement?.plan || 'free').toLowerCase() !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n const { response: createRes, data: createData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n }),\n })\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = selectedAgentId\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: selectedAgentId || undefined,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicCreated = useCallback(async (topic: TopicItem) => {\n const rawTopic = {\n id: topic.topic_id,\n name: topic.name,\n description: topic.description,\n type: topic.topic_type,\n my_role: topic.can_delete ? 'owner' : 'member',\n creator_agent_id: topic.creator_agent_id,\n member_agent_ids: topic.member_agent_ids,\n last_activity_at: topic.last_activity_at || new Date().toISOString(),\n unread_count: topic.unread_count || 0,\n }\n const prependUnique = (prev: unknown) => {\n if (!Array.isArray(prev)) return [rawTopic]\n const exists = prev.some((item) => {\n const row = item as Record\n return String(row.id || row.topic_id || '') === topic.topic_id\n })\n return exists ? prev : [rawTopic, ...prev]\n }\n await Promise.all([\n mutateTopics(prependUnique, false),\n mutateGroupTopics(prependUnique, false),\n ])\n setSelectedTopicId(topic.topic_id)\n }, [mutateGroupTopics, mutateTopics, setSelectedTopicId])\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n void mutateTopics((prev: unknown) => {\n if (!Array.isArray(prev)) return prev\n return prev.map((t) => {\n const row = t as Record\n const id = String(row.id ?? row.topic_id ?? '')\n if (id !== topicId) return t\n return { ...row, unread_count: 0 }\n })\n }, false)\n }, [mutateTopics, setSelectedTopicId])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n groupTopics={groupTopics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onTopicCreated={handleTopicCreated}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "a8ecb25a226ec7f159c9162c0ddb48d70494c873": {"t": 1785773619.8534567, "n": 118980, "h": "2426b3aa8151a8a4", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction shouldHideFeedTopic(topic: Record): boolean {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').trim()\n const creatorAgentId = String(topic.creator_agent_id || topic.creatorAgentId || '').trim()\n const originType = String(topic.origin_type || topic.originType || '').toLowerCase()\n const searchable = `${name}\\n${description}\\n${originType}\\n${creatorAgentId}\\n${collectTopicSearchText(topic)}`.toLowerCase()\n\n // Arena and Square topics have gone through a few naming/metadata revisions.\n // Keep the filter intentionally redundant so older subscribed rows disappear too.\n if (creatorAgentId === 'agent-16a45cf0dd8b') return true\n if (name === 'Arena Coach' || name.startsWith('Arena Coach:')) return true\n if (description.includes('Private Arena Coach chat')) return true\n if (\n searchable.includes('arena') ||\n searchable.includes('challenge_id') ||\n searchable.includes('challenge_slug') ||\n searchable.includes('/arena/')\n ) return true\n\n if (name.startsWith('__SQUARE__/')) return true\n if (name.startsWith('若水广场|') || name.startsWith('若水专文|')) return true\n if (name.startsWith('知乎精选:')) return true\n if (description.startsWith('[若水广场:')) return true\n if (searchable.includes('若水广场') || searchable.includes('__square__')) return true\n if (originType === 'column' || originType === 'human_post' || originType.includes('square')) return true\n\n const squareFlags = [\n 'square',\n 'is_square',\n 'square_post',\n 'square_topic',\n 'squarePost',\n 'squareTopic',\n ]\n if (squareFlags.some((key) => Boolean(topic[key]))) return true\n\n const meta = topic.metadata || topic.msg_metadata || topic.meta\n if (typeof meta === 'string' && meta.toLowerCase().includes('\"square\"')) return true\n if (meta && typeof meta === 'object' && Boolean((meta as Record).square)) return true\n\n return false\n}\n\nfunction collectTopicSearchText(value: unknown, depth = 0): string {\n if (value == null || depth > 3) return ''\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map((item) => collectTopicSearchText(item, depth + 1)).join('\\n')\n if (typeof value === 'object') {\n return Object.entries(value as Record)\n .map(([key, item]) => `${key}:${collectTopicSearchText(item, depth + 1)}`)\n .join('\\n')\n }\n return ''\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const { raw, mutate: mutateSubs } = subscribedTopicsRef.current\n if (raw && Array.isArray(raw)) {\n const now = new Date().toISOString()\n mutateSubs(\n raw.map((t) => {\n const rec = t as Record\n if (rec.id !== incomingTopicId) return t\n\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n }),\n false,\n )\n }\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [selectedTopicId, feedRaw, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: { name: string; description?: string; origin_type?: string; originType?: string; creator_agent_id?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: { id: string; name: string; description?: string; type?: string; my_role?: string; task_id?: string; runner_agent_id?: string; task_type?: string; task_mode?: string; exec_mode?: string; last_activity_at?: string; creator_agent_id?: string }) => {\n const topicType = ((topic.type || 'discussion').toLowerCase()) as 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topic.id ||\n (topic.name.includes(selectedAgentId) && topic.name.includes(humanSender))\n )\n\n return {\n topic_id: topic.id,\n name: topic.name,\n topic_type: topicType,\n unread_count: Number((topic as Record).unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n }\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId)\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (selectedTopicId && Array.isArray(subscribedTopicsRaw) && !topics.some(t => t.topic_id === selectedTopicId)) {\n setSelectedTopicId(null)\n }\n }, [topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 5 * 60_000, revalidateOnFocus: false, dedupingInterval: 60_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const cloudAgentNeedsRenewal = hasCloudAgentRecord && String(billingRaw?.entitlement?.plan || 'free').toLowerCase() !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n const { response: createRes, data: createData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n }),\n })\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (\n hostAgentId: string,\n role: AgentRoleTemplate,\n requestedAdapter?: WttConnectAdapter,\n options?: { select?: boolean; alert?: boolean },\n ) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n if (options?.select !== false) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n if (options?.alert !== false) {\n alert(`Clone Agent started: ${newAgentId}`)\n }\n return newAgentId\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = selectedAgentId\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: selectedAgentId || undefined,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n void mutateTopics((prev: unknown) => {\n if (!Array.isArray(prev)) return prev\n return prev.map((t) => {\n const row = t as Record\n const id = String(row.id ?? row.topic_id ?? '')\n if (id !== topicId) return t\n return { ...row, unread_count: 0 }\n })\n }, false)\n }, [mutateTopics, setSelectedTopicId])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "1303b87b8b98824a24e1ed6ba5792c34e114b4b3": {"t": 1785773621.580396, "n": 118797, "h": "3da58f8197135300", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction shouldHideFeedTopic(topic: Record): boolean {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').trim()\n const creatorAgentId = String(topic.creator_agent_id || topic.creatorAgentId || '').trim()\n const originType = String(topic.origin_type || topic.originType || '').toLowerCase()\n const searchable = `${name}\\n${description}\\n${originType}\\n${creatorAgentId}\\n${collectTopicSearchText(topic)}`.toLowerCase()\n\n // Arena and Square topics have gone through a few naming/metadata revisions.\n // Keep the filter intentionally redundant so older subscribed rows disappear too.\n if (creatorAgentId === 'agent-16a45cf0dd8b') return true\n if (name === 'Arena Coach' || name.startsWith('Arena Coach:')) return true\n if (description.includes('Private Arena Coach chat')) return true\n if (\n searchable.includes('arena') ||\n searchable.includes('challenge_id') ||\n searchable.includes('challenge_slug') ||\n searchable.includes('/arena/')\n ) return true\n\n if (name.startsWith('__SQUARE__/')) return true\n if (name.startsWith('若水广场|') || name.startsWith('若水专文|')) return true\n if (name.startsWith('知乎精选:')) return true\n if (description.startsWith('[若水广场:')) return true\n if (searchable.includes('若水广场') || searchable.includes('__square__')) return true\n if (originType === 'column' || originType === 'human_post' || originType.includes('square')) return true\n\n const squareFlags = [\n 'square',\n 'is_square',\n 'square_post',\n 'square_topic',\n 'squarePost',\n 'squareTopic',\n ]\n if (squareFlags.some((key) => Boolean(topic[key]))) return true\n\n const meta = topic.metadata || topic.msg_metadata || topic.meta\n if (typeof meta === 'string' && meta.toLowerCase().includes('\"square\"')) return true\n if (meta && typeof meta === 'object' && Boolean((meta as Record).square)) return true\n\n return false\n}\n\nfunction collectTopicSearchText(value: unknown, depth = 0): string {\n if (value == null || depth > 3) return ''\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map((item) => collectTopicSearchText(item, depth + 1)).join('\\n')\n if (typeof value === 'object') {\n return Object.entries(value as Record)\n .map(([key, item]) => `${key}:${collectTopicSearchText(item, depth + 1)}`)\n .join('\\n')\n }\n return ''\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [agentsLoaded, setAgentsLoaded] = useState(false)\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n } finally {\n setAgentsLoaded(true)\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const { raw, mutate: mutateSubs } = subscribedTopicsRef.current\n if (raw && Array.isArray(raw)) {\n const now = new Date().toISOString()\n mutateSubs(\n raw.map((t) => {\n const rec = t as Record\n if (rec.id !== incomingTopicId) return t\n\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n }),\n false,\n )\n }\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [selectedTopicId, feedRaw, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: { name: string; description?: string; origin_type?: string; originType?: string; creator_agent_id?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: { id: string; name: string; description?: string; type?: string; my_role?: string; task_id?: string; runner_agent_id?: string; task_type?: string; task_mode?: string; exec_mode?: string; last_activity_at?: string; creator_agent_id?: string }) => {\n const topicType = ((topic.type || 'discussion').toLowerCase()) as 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topic.id ||\n (topic.name.includes(selectedAgentId) && topic.name.includes(humanSender))\n )\n\n return {\n topic_id: topic.id,\n name: topic.name,\n topic_type: topicType,\n unread_count: Number((topic as Record).unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n }\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId)\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (selectedTopicId && Array.isArray(subscribedTopicsRaw) && !topics.some(t => t.topic_id === selectedTopicId)) {\n setSelectedTopicId(null)\n }\n }, [topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 5 * 60_000, revalidateOnFocus: false, dedupingInterval: 60_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const cloudAgentNeedsRenewal = hasCloudAgentRecord && String(billingRaw?.entitlement?.plan || 'free').toLowerCase() !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n const { response: createRes, data: createData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n }),\n })\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (hostAgentId: string, role: AgentRoleTemplate, requestedAdapter?: WttConnectAdapter) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Clone Agent started: ${newAgentId}`)\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = selectedAgentId\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: selectedAgentId || undefined,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n void mutateTopics((prev: unknown) => {\n if (!Array.isArray(prev)) return prev\n return prev.map((t) => {\n const row = t as Record\n const id = String(row.id ?? row.topic_id ?? '')\n if (id !== topicId) return t\n return { ...row, unread_count: 0 }\n })\n }, false)\n }, [mutateTopics, setSelectedTopicId])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {!agentsLoaded ? (\n
\n 正在加载 Agent...\n
\n ) : selectedTopicId && selectedTopic && agents.length > 0 ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}, "af4a0fa772c91291144f24b3bd637396955174bd": {"t": 1785773623.1217136, "n": 118447, "h": "ac85171425b25556", "c": "'use client'\n\nimport { useSession, signOut } from 'next-auth/react'\nimport { useRouter, useSearchParams } from 'next/navigation'\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport dynamic from 'next/dynamic'\nimport useSWR from 'swr'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport { WttShellV2 } from '@/components/ui/wtt-shell-v2'\nimport { ChatView, ChatMessage, ChatModelConfig, ChatSendOptions, ChatRunStatus, isProgressMessage } from '@/components/ui/chat-view'\nimport { AgentItem } from '@/components/ui/agent-column'\nimport { AgentRuntimeInfo, TopicItem, type CloudAgentCreateOptions } from '@/components/ui/topic-column'\nimport { KeyboardShortcuts } from '@/components/ui/keyboard-shortcuts'\nimport type { ContentFormat } from '@/components/ui/content-editor'\nimport type { EditorTopic } from '@/components/ui/markdown-editor'\nimport { normalizeAndFilterAgents } from '@/lib/agents'\nimport { useAgentId, buildAgentUrl } from '@/lib/hooks/use-agent-id'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { cacheKeyFromBase64, clearCachedKey, decryptReceived, encryptForSend, getCachedKey } from '@/lib/e2e-crypto'\nimport {\n getAgentRoleTemplate,\n roleTemplateFromPayload,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n} from '@/lib/agent-role-templates'\n\nconst P2P_E2E_WEB_ENABLED = process.env.NEXT_PUBLIC_WTT_P2P_E2E === '1'\nconst AGENT_TYPING_STALE_MS = 15 * 60 * 1000\nconst AGENT_STATUS_CARD_MAX_LINES = 14\nconst AGENT_STATUS_COMPLETE_HOLD_MS = 4500\nconst TOPIC_MESSAGES_CACHE_TTL_MS = 10 * 60 * 1000\n\ntype TopicTypingState = {\n agentId: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n statusLines?: ChatRunStatus['lines']\n startedAt: number\n expiresAt: number\n}\n\nconst ContentEditor = dynamic(\n () => import('@/components/ui/content-editor').then((m) => m.ContentEditor),\n { ssr: false },\n)\n\ninterface Agent {\n id: string\n agent_id: string\n display_name: string\n is_primary: boolean\n api_key?: string\n invite_code?: string\n invite_status?: 'active' | 'none'\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n role_template_id?: string\n role_template?: Record\n}\n\ninterface CloudAgentState {\n has_cloud_agent?: boolean\n agent_id?: string\n host_agent_id?: string\n status?: string\n provider?: string\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n orchestrator_status?: {\n children?: Record\n child_agents?: Array<{ agent_id?: string } | string>\n } | null\n sandbox_billing?: {\n active_minutes?: number\n estimated_rmb?: number\n currency?: string\n pricing_note?: string\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n entitlement?: {\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n }\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype WttConnectAdapter = 'codex' | 'claude-code' | 'gemini'\n\ntype AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\nfunction cloudSandboxExpectedAgentIds(state: CloudAgentState | Record | null | undefined, hostAgentId: string) {\n const ids = new Set()\n const host = String(hostAgentId || '').trim()\n if (host) ids.add(host)\n const record = (state || {}) as CloudAgentState\n const addChildAgents = (items: CloudAgentState['child_agents']) => {\n if (!Array.isArray(items)) return\n for (const item of items) {\n const id = typeof item === 'string' ? item : String(item?.agent_id || '')\n if (id.trim()) ids.add(id.trim())\n }\n }\n const addChildren = (children?: Record) => {\n if (!children || typeof children !== 'object') return\n for (const id of Object.keys(children)) {\n if (id.trim()) ids.add(id.trim())\n }\n }\n addChildren(record.children)\n addChildAgents(record.child_agents)\n addChildren(record.orchestrator_status?.children)\n addChildAgents(record.orchestrator_status?.child_agents)\n return Array.from(ids)\n}\n\nfunction topicMessagesCacheKey(topicId?: string | null, agentId?: string | null): string {\n return `wtt:topic-messages:v1:${topicId || ''}:${agentId || ''}`\n}\n\nfunction readCachedTopicMessages(topicId?: string | null, agentId?: string | null): unknown[] | undefined {\n if (typeof window === 'undefined' || !topicId || !agentId) return undefined\n try {\n const raw = window.sessionStorage.getItem(topicMessagesCacheKey(topicId, agentId))\n if (!raw) return undefined\n const parsed = JSON.parse(raw) as { ts?: number; data?: unknown }\n if (!parsed?.ts || Date.now() - parsed.ts > TOPIC_MESSAGES_CACHE_TTL_MS) return undefined\n return Array.isArray(parsed.data) ? parsed.data : undefined\n } catch {\n return undefined\n }\n}\n\nfunction writeCachedTopicMessages(topicId?: string | null, agentId?: string | null, data?: unknown): void {\n if (typeof window === 'undefined' || !topicId || !agentId || !Array.isArray(data)) return\n try {\n window.sessionStorage.setItem(topicMessagesCacheKey(topicId, agentId), JSON.stringify({ ts: Date.now(), data }))\n } catch {\n // Ignore quota/private-mode storage failures.\n }\n}\n\nfunction normalizeWttConnectAdapter(raw: unknown): WttConnectAdapter | '' {\n const value = String(raw || '').trim().toLowerCase()\n if (value === 'codex') return 'codex'\n if (value === 'claude' || value === 'claude-code' || value === 'claude_code') return 'claude-code'\n if (value === 'gemini' || value === 'gemini-cli') return 'gemini'\n return ''\n}\n\nfunction adapterDisplayName(raw: unknown): string {\n const adapter = normalizeWttConnectAdapter(raw)\n if (adapter === 'codex') return 'Codex'\n if (adapter === 'claude-code') return 'Claude Code'\n if (adapter === 'gemini') return 'Gemini'\n return 'Agent'\n}\n\nfunction responseErrorMessage(data: unknown, fallback: string): string {\n if (data && typeof data === 'object') {\n const detail = (data as { detail?: unknown; message?: unknown }).detail ?? (data as { message?: unknown }).message\n if (detail) return formatErrorDetail(detail, fallback)\n }\n return fallback\n}\n\nfunction formatErrorDetail(value: unknown, fallback = 'Unknown error'): string {\n if (value == null || value === '') return fallback\n if (typeof value === 'string') return value\n if (value instanceof Error) return value.message || fallback\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) {\n const parts = value.map((item) => formatErrorDetail(item, '')).filter(Boolean)\n return parts.join('\\n') || fallback\n }\n if (typeof value === 'object') {\n const record = value as Record\n const nested = record.message ?? record.detail ?? record.error ?? record.reason\n if (nested) return formatErrorDetail(nested, fallback)\n try {\n return JSON.stringify(value)\n } catch {\n return fallback\n }\n }\n return fallback\n}\n\nfunction delay(ms: number) {\n return new Promise((resolve) => window.setTimeout(resolve, ms))\n}\n\nasync function fetchJsonWithTimeout(input: string, init: RequestInit, timeoutMs = 25_000): Promise<{ response: Response; data: unknown }> {\n const controller = new AbortController()\n const timer = window.setTimeout(() => controller.abort(), timeoutMs)\n try {\n const response = await fetch(input, { ...init, signal: controller.signal })\n const data = await response.json().catch(() => ({}))\n return { response, data }\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`)\n }\n throw error\n } finally {\n window.clearTimeout(timer)\n }\n}\n\nfunction newClientOperationId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `${Date.now()}-${Math.random().toString(16).slice(2)}`\n }\n}\n\nfunction getHumanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction agentRoleDisplayLabel(role?: AgentRoleTemplate | null): string {\n if (!role || role.id === 'general') return ''\n return String(role.shortLabel || role.label || '').trim()\n}\n\nfunction normalizeSenderType(\n rawType: unknown,\n senderId?: string,\n knownAgentIds?: Set,\n senderDisplayName?: string,\n): 'human' | 'agent' {\n const t = String(rawType ?? '').trim().toLowerCase()\n if (t === 'human' || t === 'user' || t === 'person') return 'human'\n if (t === 'agent' || t === 'bot' || t === 'assistant' || t === 'system') return 'agent'\n\n const sid = String(senderId ?? '').trim()\n const sidLower = sid.toLowerCase()\n\n if (knownAgentIds?.has(sid)) return 'agent'\n if (sidLower.startsWith('agent_') || sidLower.startsWith('agent-')) return 'agent'\n\n if (sidLower.startsWith('user_') || sidLower.startsWith('human_')) return 'human'\n if (/^\\d{5,}$/.test(sid)) return 'human'\n\n const name = String(senderDisplayName ?? '').trim().toLowerCase()\n if (name.startsWith('user ') || name.startsWith('wtt user') || name.includes('群众')) return 'human'\n\n return 'agent'\n}\n\nfunction stripSourceMarker(content: string): string {\n const text = String(content || '')\n if (!text.startsWith('┌─ 来源标识')) return text\n const markerEnd = text.indexOf('\\n└────────────────────\\n')\n if (markerEnd === -1) return text\n return text.slice(markerEnd + '\\n└────────────────────\\n'.length)\n}\n\nfunction shouldDisplayMessage(semanticTypeRaw: unknown, contentRaw: unknown): boolean {\n const semantic = String(semanticTypeRaw ?? '').trim().toLowerCase()\n if (semantic === 'system' || semantic === 'notification' || semantic === 'command') return false\n\n const content = String(contentRaw ?? '')\n if (!content.trim()) return false\n if (content.includes('[system:p2p_init]')) return false\n if (content.includes('[System] P2P channel established')) return false\n if (isProgressMessage(content)) return false\n\n return true\n}\n\nfunction clearTypingAfterAgentReply(\n prev: Record,\n topicId: string,\n agentId?: string,\n messageTimestamp?: string,\n): Record {\n const existing = prev[topicId]\n if (!existing) return prev\n if (agentId && existing.agentId && existing.agentId !== agentId) return prev\n\n if (messageTimestamp) {\n const messageTime = new Date(messageTimestamp).getTime()\n if (Number.isFinite(messageTime) && messageTime + 2000 < existing.startedAt) return prev\n }\n\n const now = Date.now()\n return {\n ...prev,\n [topicId]: appendTypingStatus(existing, {\n agentId: agentId || existing.agentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: AGENT_STATUS_COMPLETE_HOLD_MS,\n }, now),\n }\n}\n\nfunction appendTypingStatus(\n existing: TopicTypingState | undefined,\n update: {\n agentId?: string\n agentName?: string\n statusText?: string\n statusKind?: string\n adapter?: string\n model?: string\n ttlMs?: number\n },\n now: number,\n): TopicTypingState {\n const text = String(update.statusText || '').trim()\n const kind = String(update.statusKind || '').trim() || undefined\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) {\n lines[lines.length - 1] = { ...last, ts: now }\n } else {\n lines.push({\n id: `${now}-${lines.length}-${kind || 'status'}`,\n text,\n kind,\n ts: now,\n })\n }\n }\n\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusLines: lines.slice(-AGENT_STATUS_CARD_MAX_LINES),\n startedAt: existing?.startedAt || now,\n // Safety fallback only. Normal lifecycle is cleared by the agent reply.\n expiresAt: now + (update.ttlMs || AGENT_TYPING_STALE_MS),\n }\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = String(contentRaw || '').trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) {\n return { text: `${actor} 会话已启动`, kind: 'session' }\n }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 120)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction shouldHideFeedTopic(topic: Record): boolean {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').trim()\n const creatorAgentId = String(topic.creator_agent_id || topic.creatorAgentId || '').trim()\n const originType = String(topic.origin_type || topic.originType || '').toLowerCase()\n const searchable = `${name}\\n${description}\\n${originType}\\n${creatorAgentId}\\n${collectTopicSearchText(topic)}`.toLowerCase()\n\n // Arena and Square topics have gone through a few naming/metadata revisions.\n // Keep the filter intentionally redundant so older subscribed rows disappear too.\n if (creatorAgentId === 'agent-16a45cf0dd8b') return true\n if (name === 'Arena Coach' || name.startsWith('Arena Coach:')) return true\n if (description.includes('Private Arena Coach chat')) return true\n if (\n searchable.includes('arena') ||\n searchable.includes('challenge_id') ||\n searchable.includes('challenge_slug') ||\n searchable.includes('/arena/')\n ) return true\n\n if (name.startsWith('__SQUARE__/')) return true\n if (name.startsWith('若水广场|') || name.startsWith('若水专文|')) return true\n if (name.startsWith('知乎精选:')) return true\n if (description.startsWith('[若水广场:')) return true\n if (searchable.includes('若水广场') || searchable.includes('__square__')) return true\n if (originType === 'column' || originType === 'human_post' || originType.includes('square')) return true\n\n const squareFlags = [\n 'square',\n 'is_square',\n 'square_post',\n 'square_topic',\n 'squarePost',\n 'squareTopic',\n ]\n if (squareFlags.some((key) => Boolean(topic[key]))) return true\n\n const meta = topic.metadata || topic.msg_metadata || topic.meta\n if (typeof meta === 'string' && meta.toLowerCase().includes('\"square\"')) return true\n if (meta && typeof meta === 'object' && Boolean((meta as Record).square)) return true\n\n return false\n}\n\nfunction collectTopicSearchText(value: unknown, depth = 0): string {\n if (value == null || depth > 3) return ''\n if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map((item) => collectTopicSearchText(item, depth + 1)).join('\\n')\n if (typeof value === 'object') {\n return Object.entries(value as Record)\n .map(([key, item]) => `${key}:${collectTopicSearchText(item, depth + 1)}`)\n .join('\\n')\n }\n return ''\n}\n\nfunction toChatTaskType(taskTypeRaw?: string, taskModeRaw?: string, execModeRaw?: string): 'code' | 'research' | 'general' | null {\n const raw = `${String(taskTypeRaw || '').toLowerCase()} ${String(taskModeRaw || '').toLowerCase()} ${String(execModeRaw || '').toLowerCase()}`\n if (raw.includes('research')) return 'research'\n if (raw.includes('code')) return 'code'\n if (raw.includes('general')) return 'general'\n return null\n}\n\nfunction parseModelHintFromMetadata(metaRaw: unknown): { model_hint?: string; reasoning_hint?: 'off' | 'low' | 'medium' | 'high' } {\n let meta: Record | null = null\n\n if (metaRaw && typeof metaRaw === 'object') {\n meta = metaRaw as Record\n } else if (typeof metaRaw === 'string' && metaRaw.trim()) {\n try {\n const parsed = JSON.parse(metaRaw)\n if (parsed && typeof parsed === 'object') meta = parsed as Record\n } catch {\n // ignore malformed metadata\n }\n }\n\n if (!meta) return {}\n\n const cfgRaw = meta.model_config\n const cfg = (cfgRaw && typeof cfgRaw === 'object') ? (cfgRaw as Record) : null\n if (!cfg) return {}\n\n const model = String(cfg.model || '').trim()\n const effortRaw = String(cfg.reasoning_effort || cfg.reasoningEffort || '').trim().toLowerCase()\n const reasoning = (['off', 'low', 'medium', 'high'].includes(effortRaw)\n ? effortRaw\n : '') as '' | 'off' | 'low' | 'medium' | 'high'\n\n return {\n ...(model ? { model_hint: model } : {}),\n ...(reasoning ? { reasoning_hint: reasoning } : {}),\n }\n}\n\nfunction normalizeFeed(raw: unknown, knownAgentIds?: Set): ChatMessage[] {\n if (!raw || typeof raw !== 'object') return []\n\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n\n const normalized: ChatMessage[] = []\n\n for (let index = 0; index < rows.length; index++) {\n const row = rows[index]\n const data = row as Record\n const semanticType = String(data.semantic_type ?? '')\n const cleanedContent = stripSourceMarker(String(data.content ?? ''))\n if (!shouldDisplayMessage(semanticType, cleanedContent)) continue\n\n const senderId = String(data.sender_id ?? 'unknown')\n const senderDisplayName = data.sender_display_name ? String(data.sender_display_name) : undefined\n const modelHint = parseModelHintFromMetadata(data.metadata)\n normalized.push({\n message_id: String(data.message_id ?? data.id ?? `msg-${index}`),\n topic_id: String(data.topic_id ?? ''),\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType(data.sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: data.sender_avatar_url ? String(data.sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean(data.encrypted),\n timestamp: String(data.timestamp ?? data.created_at ?? new Date().toISOString()),\n semantic_type: semanticType,\n task_id: data.task_id ? String(data.task_id) : undefined,\n task_status: data.task_status ? String(data.task_status) : undefined,\n task_title: data.task_title ? String(data.task_title) : undefined,\n runner_agent_id: data.runner_agent_id ? String(data.runner_agent_id) : undefined,\n exec_mode: data.exec_mode ? String(data.exec_mode) : undefined,\n reply_to: data.reply_to ? String(data.reply_to) : undefined,\n ...modelHint,\n })\n }\n\n return normalized\n}\n\nfunction feedRows(raw: unknown): Record[] {\n if (!raw || typeof raw !== 'object') return []\n const rows = Array.isArray(raw)\n ? raw\n : Array.isArray((raw as { messages?: unknown[] }).messages)\n ? (raw as { messages: unknown[] }).messages\n : []\n return rows.filter((row): row is Record => Boolean(row && typeof row === 'object'))\n}\n\nexport default function FeedPageWrapper() {\n return (\n \n \n \n )\n}\n\n// Inline member row\nfunction MemberRow({ member, isSelf, onRequestPrivateDiscuss }: {\n member: { agent_id: string; display_name: string }\n isSelf: boolean\n onRequestPrivateDiscuss?: (targetAgentId: string, targetDisplayName: string) => void\n}) {\n return (\n
\n
\n
{member.display_name}
\n
{member.agent_id}
\n
\n {!isSelf && onRequestPrivateDiscuss && (\n { e.stopPropagation(); onRequestPrivateDiscuss(member.agent_id, member.display_name) }}\n className=\"rounded bg-slate-100 dark:bg-zinc-700 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-400 transition hover:bg-slate-200 dark:hover:bg-zinc-600 shrink-0\"\n title={`Request private discuss with ${member.display_name}`}\n >\n 💬\n \n )}\n
\n )\n}\n\nfunction FeedPageInner() {\n const { data: session, status } = useSession()\n const { t } = useI18n()\n const router = useRouter()\n const [agents, setAgents] = useState([])\n const [selectedAgentId, setSelectedAgentId] = useAgentId()\n const [agentRoleMap, setAgentRoleMap] = useState>({})\n const [agentRoleTemplateMap, setAgentRoleTemplateMap] = useState>({})\n const [selectedTopicId, _setSelectedTopicId] = useState(() => {\n if (typeof window !== 'undefined') {\n return localStorage.getItem('wtt_selected_topic_id') || null\n }\n return null\n })\n const [p2pTopicByAgentId, setP2pTopicByAgentId] = useState>({})\n const setSelectedTopicId = useCallback((id: string | null) => {\n _setSelectedTopicId(id)\n try {\n if (id) localStorage.setItem('wtt_selected_topic_id', id)\n else localStorage.removeItem('wtt_selected_topic_id')\n } catch {}\n }, [])\n const [composerFocusNonce, setComposerFocusNonce] = useState(0)\n const [pendingComposerFocusTopicId, setPendingComposerFocusTopicId] = useState(null)\n const [allMessages, setAllMessages] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const typingBaselineAgentMessageIdsRef = useRef>>({})\n // Cache successful decrypt results by message_id + ciphertext to avoid repeated CPU work.\n const decryptCacheRef = useRef>(new Map())\n // Keep feed polling fallback enabled only when realtime WS is not healthy.\n const [wsConnectedForPoll, setWsConnectedForPoll] = useState(false)\n const [hasOlder, setHasOlder] = useState(false)\n const [loadingOlder, setLoadingOlder] = useState(false)\n const [editorOpen, setEditorOpen] = useState(false)\n\n useEffect(() => {\n try {\n const raw = localStorage.getItem('wtt:feed-agent-role-map')\n if (!raw) return\n const parsed = JSON.parse(raw)\n if (parsed && typeof parsed === 'object') setAgentRoleMap(parsed as Record)\n } catch {\n // ignore local role-map errors\n }\n }, [])\n\n const persistAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n setAgentRoleMap((prev) => {\n const next = { ...prev, [agentId]: role.id }\n if (role.id === 'general') delete next[agentId]\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(next))\n } catch {\n // ignore local storage failures\n }\n return next\n })\n setAgentRoleTemplateMap((prev) => {\n const next = { ...prev }\n if (role.id === 'general') delete next[agentId]\n else next[agentId] = role\n return next\n })\n if (session?.accessToken) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }),\n })\n .then((res) => {\n if (res.ok) {\n setAgents((prev) => prev.map((agent) => (\n agent.agent_id === agentId\n ? {\n ...agent,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n }\n : agent\n )))\n }\n })\n .catch(() => {})\n }\n }, [session?.accessToken])\n\n const handleAssignAgentRole = useCallback((agentId: string, roleId: string) => {\n persistAgentRole(agentId, getAgentRoleTemplate(roleId))\n }, [persistAgentRole])\n\n const handleSaveAgentRole = useCallback((agentId: string, role: AgentRoleTemplate) => {\n persistAgentRole(agentId, role)\n }, [persistAgentRole])\n\n const [kbLoading, setKbLoading] = useState(false)\n const handleOpenKnowledgeRoot = useCallback(async () => {\n if (kbLoading) return\n if (!session?.accessToken) {\n alert('Please log in first')\n return\n }\n setKbLoading(true)\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/kb/personal`, {\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (!resp.ok) {\n const errText = await resp.text().catch(() => '')\n console.error('KB personal error:', resp.status, errText)\n alert(`Failed to open Knowledge Root (${resp.status})`)\n return\n }\n const kb = await resp.json()\n if (kb?.id) {\n router.push(`/tasks/kb/${kb.id}`)\n } else {\n alert('Failed to create Knowledge Root')\n }\n } catch (e) {\n console.error('KB redirect failed:', e)\n alert('Network error opening Knowledge Root')\n } finally {\n setKbLoading(false)\n }\n }, [session?.accessToken, router, kbLoading])\n const [membersOpen, setMembersOpen] = useState(false)\n const [inviteMemberOpen, setInviteMemberOpen] = useState(false)\n const [inviteAgentId, setInviteAgentId] = useState('')\n const [invitingMember, setInvitingMember] = useState(false)\n const [forceOpenSettingsPage, setForceOpenSettingsPage] = useState<'binding' | 'profile' | 'membership' | null>(null)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n // Track newly created task that needs rename on first message\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n // Track active worker session context for persona injection\n const activeWorkerSessionRef = useRef<{\n workerId: string\n personaMd: string\n workerMd: string\n isFirstSession: boolean\n personaChanged: boolean\n topicId: string\n } | null>(null)\n\n const loadAgents = useCallback(async () => {\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken ?? ''}`,\n },\n })\n\n if (!response.ok) return\n\n const data = await response.json()\n const list = normalizeAndFilterAgents(data)\n setAgents(list)\n\n const storedRoleMap: Record = {}\n try {\n const rawRoleMap = localStorage.getItem('wtt:feed-agent-role-map')\n const parsedRoleMap = rawRoleMap ? JSON.parse(rawRoleMap) : null\n if (parsedRoleMap && typeof parsedRoleMap === 'object') {\n for (const [agentId, roleId] of Object.entries(parsedRoleMap)) {\n if (typeof roleId === 'string') storedRoleMap[agentId] = roleId\n }\n }\n } catch {\n // ignore local role-map errors\n }\n\n const nextRoleMap = { ...storedRoleMap }\n const nextRoleTemplateMap: Record = {}\n const roleMigrations: Array<{ agentId: string; role: ReturnType }> = []\n for (const agent of list) {\n if (agent.role_template_id) {\n nextRoleMap[agent.agent_id] = agent.role_template_id\n nextRoleTemplateMap[agent.agent_id] = roleTemplateFromPayload(agent.role_template_id, agent.role_template)\n continue\n }\n\n const localRoleId = nextRoleMap[agent.agent_id]\n const localRole = localRoleId ? getAgentRoleTemplate(localRoleId) : null\n if (localRole && localRole.id !== 'general') {\n roleMigrations.push({ agentId: agent.agent_id, role: localRole })\n continue\n }\n\n delete nextRoleMap[agent.agent_id]\n }\n setAgentRoleMap(nextRoleMap)\n setAgentRoleTemplateMap(nextRoleTemplateMap)\n try {\n localStorage.setItem('wtt:feed-agent-role-map', JSON.stringify(nextRoleMap))\n } catch {\n // ignore local storage failures\n }\n if (session?.accessToken && roleMigrations.length > 0) {\n for (const item of roleMigrations) {\n fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(item.agentId)}/profile`, {\n method: 'PATCH',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n role_template_id: item.role.id,\n role_template: serializeAgentRoleTemplate(item.role),\n }),\n }).catch(() => {})\n }\n }\n\n const fallback = list[0]\n\n if (fallback) {\n // Only override if current selection is empty or no longer valid\n if (!selectedAgentId || !list.some((a) => a.agent_id === selectedAgentId)) {\n setSelectedAgentId(fallback.agent_id)\n }\n if (fallback.api_key) {\n wttApi.setToken(fallback.api_key)\n }\n }\n } catch {\n // Keep page resilient\n }\n }, [selectedAgentId, session?.accessToken, setSelectedAgentId])\n\n // Lookup map: agent_id → display_name (for enriching chat messages)\n const agentNameMap = useMemo(() => {\n const map: Record = {}\n for (const a of agents) map[a.agent_id] = a.display_name\n return map\n }, [agents])\n\n const knownAgentIds = useMemo(() => new Set(agents.map((a) => a.agent_id)), [agents])\n const cloudSandboxAgentIds = useMemo(() => {\n const ids = new Set()\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (agent.is_cloud_sandbox || hostId) {\n ids.add(agent.agent_id)\n if (hostId) ids.add(hostId)\n }\n }\n return ids\n }, [agents])\n\n const agentRoleLabelMap = useMemo(() => {\n const map: Record = {}\n for (const agent of agents) {\n const role = agentRoleTemplateMap[agent.agent_id] || getAgentRoleTemplate(agentRoleMap[agent.agent_id])\n const label = agentRoleDisplayLabel(role)\n if (label) map[agent.agent_id] = label\n }\n return map\n }, [agents, agentRoleMap, agentRoleTemplateMap])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n router.push('/login')\n return\n }\n\n if (status !== 'authenticated') {\n return\n }\n\n loadAgents()\n }, [status, router, loadAgents])\n\n useEffect(() => {\n const selected = agents.find((agent) => agent.agent_id === selectedAgentId)\n if (selected?.api_key) {\n wttApi.setToken(selected.api_key)\n }\n }, [agents, selectedAgentId])\n\n const { data: feedRaw, error, mutate } = useSWR(\n selectedAgentId && session?.accessToken && selectedTopicId ? ['topic-messages', selectedTopicId, selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?limit=100&agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(responseErrorMessage(payload, `HTTP ${response.status}`))\n }\n\n return response.json()\n },\n {\n // WS-first: disable regular polling when websocket is healthy.\n // Keep 5s fallback only while WS is disconnected/unhealthy.\n refreshInterval: wsConnectedForPoll ? 0 : 5000,\n dedupingInterval: 10000,\n revalidateOnFocus: false,\n keepPreviousData: true,\n fallbackData: readCachedTopicMessages(selectedTopicId, selectedAgentId),\n onSuccess: (data) => writeCachedTopicMessages(selectedTopicId, selectedAgentId, data),\n }\n )\n\n // WebSocket for real-time messages\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=web` : ''\n const subscribedTopicsRef = useRef<{ raw: unknown[] | null; mutate: (data?: unknown, revalidate?: boolean) => void }>({ raw: null, mutate: () => {} })\n const decryptMessageForDisplay = useCallback(async (message: ChatMessage): Promise => {\n if (!message.encrypted) return message\n\n const cacheKey = `${message.message_id}:${message.content}`\n const cached = decryptCacheRef.current.get(cacheKey)\n if (cached !== undefined) {\n return { ...message, content: cached }\n }\n\n const dec = await decryptReceived(message.content, true)\n // Cache only successful decrypts so key bootstrap can recover locked messages immediately.\n if (!dec.decryptFailed) {\n decryptCacheRef.current.set(cacheKey, dec.text)\n if (decryptCacheRef.current.size > 5000) {\n const firstKey = decryptCacheRef.current.keys().next().value\n if (firstKey) decryptCacheRef.current.delete(firstKey)\n }\n }\n return { ...message, content: dec.text }\n }, [])\n\n const decryptMessagesForDisplay = useCallback(async (messages: ChatMessage[]): Promise => {\n return Promise.all(messages.map((m) => decryptMessageForDisplay(m)))\n }, [decryptMessageForDisplay])\n\n const handleWsMessage = useCallback(\n (msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n\n if (rawEvent.type === 'typing') {\n const topicId = String(rawEvent.topic_id || '')\n if (!topicId) return\n\n const state = String(rawEvent.state || 'start').toLowerCase()\n if (state === 'stop') {\n // Agent-side stop events can arrive before the actual reply is persisted\n // and pushed to Web. Keep the indicator until a real agent message arrives.\n return\n }\n\n const agentId = String(rawEvent.agent_id || '')\n const agentName = String(rawEvent.agent_display_name || '') || agentNameMap[agentId] || undefined\n const statusText = statusTextFromTypingEvent(rawEvent)\n const statusKind = statusKindFromTypingEvent(rawEvent)\n const adapter = String(rawEvent.adapter || '').trim() || undefined\n const model = String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined\n const ttlMsRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlMsRaw) && ttlMsRaw > 0 ? Math.max(ttlMsRaw, 30000) : undefined\n\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [topicId]: appendTypingStatus(prev[topicId], {\n agentId,\n agentName,\n statusText,\n statusKind,\n adapter,\n model,\n ttlMs,\n }, now),\n }))\n return\n }\n\n if (rawEvent.type === 'task_status') {\n const topicId = String(rawEvent.topic_id || '')\n const status = String(rawEvent.status || '').toLowerCase()\n if (!topicId || !status) return\n\n const taskId = String(rawEvent.task_id || '')\n const title = String(rawEvent.title || '')\n const runnerAgentId = String(rawEvent.runner_agent_id || rawEvent.owner_agent_id || '') || undefined\n const senderId = runnerAgentId || 'task-system'\n const senderName = runnerAgentId ? (agentNameMap[runnerAgentId] || runnerAgentId) : 'Task System'\n\n const statusMsgId = `ws-task-status:${taskId || topicId}:${status}`\n const synthetic: ChatMessage = {\n message_id: statusMsgId,\n sender_id: senderId,\n sender_display_name: senderName,\n sender_type: 'agent',\n content: '',\n timestamp: new Date().toISOString(),\n semantic_type: 'task_status',\n task_id: taskId || undefined,\n task_status: status,\n task_title: title || undefined,\n runner_agent_id: runnerAgentId,\n exec_mode: rawEvent.exec_mode ? String(rawEvent.exec_mode) : undefined,\n }\n\n if (topicId === selectedTopicId) {\n setAllMessages((prev) => {\n const idx = prev.findIndex((m) => m.message_id === statusMsgId)\n if (idx >= 0) {\n const next = [...prev]\n next[idx] = { ...next[idx], ...synthetic }\n return next\n }\n return [...prev, synthetic]\n })\n }\n\n return\n }\n\n if (msg.type !== 'new_message' || !msg.message) return\n const incomingTopicId = msg.message.topic_id\n\n const semanticType = String((msg.message as Record).semantic_type ?? '')\n const rawContent = String((msg.message as Record).content ?? '')\n const cleanedContent = stripSourceMarker(rawContent)\n const displayable = shouldDisplayMessage(semanticType, cleanedContent)\n\n // Bump activity and unread counters for the topic that received the message.\n const { raw, mutate: mutateSubs } = subscribedTopicsRef.current\n if (raw && Array.isArray(raw)) {\n const now = new Date().toISOString()\n mutateSubs(\n raw.map((t) => {\n const rec = t as Record\n if (rec.id !== incomingTopicId) return t\n\n const currentUnread = Number(rec.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...rec, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...rec,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n }),\n false,\n )\n }\n\n if (incomingTopicId !== selectedTopicId) return\n if (cleanedContent.trim().startsWith('[TASK_STATUS]')) {\n const senderId = String(msg.message.sender_id || '')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType === 'agent') {\n const now = Date.now()\n setTypingByTopic((prev) => {\n const existing = prev[incomingTopicId]\n if (!existing) return prev\n const progressStatus = statusFromProgressMessage(cleanedContent, existing.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [incomingTopicId]: appendTypingStatus(existing, {\n agentId: senderId || existing.agentId,\n agentName: senderDisplayName || existing.agentName,\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: existing.adapter,\n ttlMs: 60000,\n }, now),\n }\n })\n }\n }\n if (!displayable) return\n\n const senderId = String(msg.message.sender_id || 'unknown')\n const senderDisplayName = (msg.message as Record).sender_display_name\n ? String((msg.message as Record).sender_display_name)\n : agentNameMap[senderId] || undefined\n const modelHint = parseModelHintFromMetadata((msg.message as Record).metadata)\n const incomingBase: ChatMessage = {\n message_id: msg.message.id,\n topic_id: incomingTopicId,\n sender_id: senderId,\n sender_display_name: senderDisplayName,\n sender_type: normalizeSenderType((msg.message as Record).sender_type, senderId, knownAgentIds, senderDisplayName),\n sender_avatar_url: (msg.message as Record).sender_avatar_url ? String((msg.message as Record).sender_avatar_url) : undefined,\n content: cleanedContent,\n encrypted: Boolean((msg.message as Record).encrypted),\n timestamp: msg.message.created_at,\n semantic_type: semanticType,\n reply_to: (msg.message as Record).reply_to ? String((msg.message as Record).reply_to) : undefined,\n ...modelHint,\n }\n\n if (incomingBase.sender_type === 'agent') {\n delete typingBaselineAgentMessageIdsRef.current[incomingTopicId]\n setTypingByTopic((prev) => clearTypingAfterAgentReply(prev, incomingTopicId, senderId, incomingBase.timestamp))\n }\n\n void (async () => {\n const incoming = await decryptMessageForDisplay(incomingBase)\n setAllMessages((prev) => {\n if (prev.some((m) => m.message_id === incoming.message_id)) return prev\n return [...prev, incoming]\n })\n })()\n },\n [selectedTopicId, agentNameMap, knownAgentIds, decryptMessageForDisplay],\n )\n const { state: wsState, sendAction } = useWebSocket({\n url: wsUrl,\n enabled: !!selectedAgentId,\n token: session?.accessToken || undefined,\n onMessage: handleWsMessage,\n })\n\n const prevWsStateRef = useRef('disconnected')\n useEffect(() => {\n const connected = wsState === 'connected'\n setWsConnectedForPoll(connected)\n\n // After reconnect, run one immediate HTTP backfill to avoid missed messages\n // during transient WS outages.\n if (connected && prevWsStateRef.current !== 'connected') {\n void mutate()\n }\n prevWsStateRef.current = wsState\n }, [wsState, mutate])\n\n const e2eBootstrapRequestedRef = useRef(null)\n const e2eBootstrapTimerRef = useRef | null>(null)\n const e2eRetryDelayRef = useRef(3000)\n const lastE2EAgentRef = useRef(null)\n const [e2eBootstrapSeq, setE2eBootstrapSeq] = useState(0)\n\n useEffect(() => {\n if (P2P_E2E_WEB_ENABLED) return\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n }, [])\n\n useEffect(() => {\n if (!selectedAgentId) return\n\n if (!P2P_E2E_WEB_ENABLED) {\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n return\n }\n\n // On first mount, keep existing cached key (it may already match this agent).\n if (!lastE2EAgentRef.current) {\n lastE2EAgentRef.current = selectedAgentId\n return\n }\n\n if (lastE2EAgentRef.current === selectedAgentId) return\n lastE2EAgentRef.current = selectedAgentId\n\n // Agent really changed: clear cached key and force re-bootstrap.\n clearCachedKey()\n decryptCacheRef.current.clear()\n e2eBootstrapRequestedRef.current = null\n e2eRetryDelayRef.current = 3000\n }, [selectedAgentId])\n\n useEffect(() => {\n if (!P2P_E2E_WEB_ENABLED) return\n if (!selectedAgentId) return\n if (!session?.accessToken) return\n if (getCachedKey()) return\n if (e2eBootstrapRequestedRef.current === selectedAgentId) return\n\n e2eBootstrapRequestedRef.current = selectedAgentId\n void (async () => {\n let bootstrapped = false\n\n // Single HTTP bootstrap path (server bridges to plugin over WS).\n try {\n const resp = await fetch(\n `${CLIENT_WTT_API_BASE}/agents/e2e-key?agent_id=${encodeURIComponent(selectedAgentId)}`,\n { headers: { Authorization: `Bearer ${session.accessToken}` } },\n )\n if (resp.ok) {\n const payload = (await resp.json()) as { key_b64?: string }\n const keyB64 = String(payload?.key_b64 || '')\n if (keyB64 && cacheKeyFromBase64(keyB64)) {\n bootstrapped = true\n e2eRetryDelayRef.current = 3000\n // Force one refresh so previously locked encrypted rows can re-render decrypted.\n void mutate()\n }\n }\n } catch {\n // ignore and retry below\n }\n\n if (!bootstrapped) {\n // best-effort bootstrap (plugin offline / auth race / no peer plugin)\n e2eBootstrapRequestedRef.current = null\n if (e2eBootstrapTimerRef.current) clearTimeout(e2eBootstrapTimerRef.current)\n const retryDelay = e2eRetryDelayRef.current\n e2eRetryDelayRef.current = Math.min(retryDelay * 2, 30000)\n e2eBootstrapTimerRef.current = setTimeout(() => {\n setE2eBootstrapSeq((n) => n + 1)\n }, retryDelay)\n }\n })()\n\n return () => {\n if (e2eBootstrapTimerRef.current) {\n clearTimeout(e2eBootstrapTimerRef.current)\n e2eBootstrapTimerRef.current = null\n }\n }\n }, [selectedAgentId, session?.accessToken, e2eBootstrapSeq, mutate])\n\n useEffect(() => {\n const timer = setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next: Record = {}\n for (const [topicId, v] of Object.entries(prev)) {\n if (v.expiresAt > now) next[topicId] = v\n else changed = true\n }\n return changed ? next : prev\n })\n }, 1000)\n return () => clearInterval(timer)\n }, [])\n\n const prevTopicRef = useRef(selectedTopicId)\n useEffect(() => {\n let cancelled = false\n void (async () => {\n const normalizedRaw = normalizeFeed(feedRaw, knownAgentIds)\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const normalized = await decryptMessagesForDisplay(normalizedRaw)\n if (cancelled) return\n\n const topicChanged = prevTopicRef.current !== selectedTopicId\n prevTopicRef.current = selectedTopicId\n if (topicChanged || normalized.length === 0) {\n // Full replace on topic switch or empty data\n setAllMessages(normalized)\n } else {\n setAllMessages((prev) => {\n if (prev.length === 0) return normalized\n // Merge: preserve DOM/scroll position during polling refreshes\n const existingIds = new Set(prev.map(m => m.message_id))\n const newMsgs = normalized.filter(m => !existingIds.has(m.message_id))\n if (newMsgs.length === 0 && prev.length === normalized.length) return prev\n const normalizedMap = new Map(normalized.map(m => [m.message_id, m]))\n const merged = prev\n .filter(m => normalizedMap.has(m.message_id))\n .map(m => normalizedMap.get(m.message_id)!)\n for (const m of newMsgs) merged.push(m)\n merged.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n return merged\n })\n }\n setHasOlder(normalized.length >= 100)\n if (selectedTopicId) {\n setTypingByTopic((prev) => {\n const existing = prev[selectedTopicId]\n if (!existing) return prev\n const progressRows = feedRows(feedRaw)\n let nextState = existing\n for (const row of progressRows) {\n const rowTopicId = String(row.topic_id ?? '')\n if (rowTopicId && rowTopicId !== selectedTopicId) continue\n const senderId = String(row.sender_id ?? '')\n const senderDisplayName = row.sender_display_name ? String(row.sender_display_name) : agentNameMap[senderId] || undefined\n const senderType = normalizeSenderType(row.sender_type, senderId, knownAgentIds, senderDisplayName)\n if (senderType !== 'agent') continue\n if (existing.agentId && senderId && senderId !== existing.agentId) continue\n const rowTime = new Date(String(row.timestamp ?? row.created_at ?? '')).getTime()\n if (!Number.isFinite(rowTime) || rowTime + 2000 < existing.startedAt) continue\n const progress = statusFromProgressMessage(stripSourceMarker(String(row.content ?? '')), nextState.adapter)\n if (!progress) continue\n nextState = appendTypingStatus(nextState, {\n agentId: senderId || nextState.agentId,\n agentName: senderDisplayName || nextState.agentName,\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState.adapter,\n ttlMs: 60000,\n }, Math.max(Date.now(), rowTime))\n }\n const baselineIds = typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n const reply = normalized.find((m) => (\n m.sender_type === 'agent' &&\n (!nextState.agentId || m.sender_id === nextState.agentId) &&\n (!baselineIds || !baselineIds.has(m.message_id)) &&\n new Date(m.timestamp).getTime() + 2000 >= nextState.startedAt\n ))\n if (!reply) {\n return nextState === existing ? prev : { ...prev, [selectedTopicId]: nextState }\n }\n delete typingBaselineAgentMessageIdsRef.current[selectedTopicId]\n return clearTypingAfterAgentReply(prev, selectedTopicId, reply.sender_id, reply.timestamp)\n })\n }\n })()\n\n return () => {\n cancelled = true\n }\n }, [feedRaw, selectedTopicId, knownAgentIds, decryptMessagesForDisplay, agentNameMap])\n\n // Enrich messages: replace raw agent_id fallback with display_name from agentNameMap\n const enrichedMessages = useMemo(() => {\n return allMessages.map(m => {\n const name = agentNameMap[m.sender_id]\n const isCloud = cloudSandboxAgentIds.has(m.sender_id)\n if (m.sender_display_name && m.sender_display_name !== m.sender_id) {\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n }\n if (name) return { ...m, sender_display_name: name, is_cloud_sandbox: isCloud }\n return m.is_cloud_sandbox === isCloud ? m : { ...m, is_cloud_sandbox: isCloud }\n })\n }, [allMessages, agentNameMap, cloudSandboxAgentIds])\n\n const loadOlderMessages = useCallback(async () => {\n if (!selectedTopicId || loadingOlder || allMessages.length === 0) return\n setLoadingOlder(true)\n try {\n const oldest = allMessages[0]\n const older = await wttApi.getTopicMessages(selectedTopicId, 100, {\n before: oldest.timestamp,\n agentId: selectedAgentId,\n })\n\n const normalizedOlderRaw = normalizeFeed(older, knownAgentIds)\n const normalizedOlder = await decryptMessagesForDisplay(normalizedOlderRaw)\n if (normalizedOlder.length === 0) {\n setHasOlder(false)\n } else {\n const merged = [...normalizedOlder, ...allMessages]\n const dedup = Array.from(new Map(merged.map((m) => [m.message_id, m])).values())\n .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n setAllMessages(dedup)\n setHasOlder(normalizedOlder.length >= 100)\n }\n } catch {\n setHasOlder(false)\n } finally {\n setLoadingOlder(false)\n }\n }, [selectedTopicId, loadingOlder, allMessages, knownAgentIds, decryptMessagesForDisplay, selectedAgentId])\n\n const { data: subscribedTopicsRaw, mutate: mutateTopics } = useSWR(\n selectedAgentId && session?.accessToken ? ['subscribed', selectedAgentId, session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${selectedAgentId}`, {\n headers: {\n Authorization: `Bearer ${session?.accessToken}`,\n },\n })\n\n if (!response.ok) {\n const payload = await response.json().catch(() => ({ detail: `HTTP ${response.status}` }))\n throw new Error(payload.detail ?? `HTTP ${response.status}`)\n }\n\n return response.json()\n },\n {\n // WS updates topic activity; keep low-frequency polling as safety net.\n refreshInterval: wsState === 'connected' ? 15000 : 5000,\n revalidateOnFocus: true,\n dedupingInterval: 3000,\n }\n )\n\n // Keep ref in sync for WS handler (avoids circular dependency)\n useEffect(() => {\n subscribedTopicsRef.current = { raw: subscribedTopicsRaw ?? null, mutate: mutateTopics }\n }, [subscribedTopicsRaw, mutateTopics])\n\n // After topic messages are loaded (which marks latest as read server-side),\n // sync subscribed topic list to clear unread badges after refresh.\n useEffect(() => {\n if (!selectedTopicId) return\n if (!Array.isArray(feedRaw) || feedRaw.length === 0) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [selectedTopicId, feedRaw, mutateTopics])\n\n // Poll pending P2P requests for notifications\n // session.userId is the WTT backend UUID; session.user.id may not be set by NextAuth\n const wttUserId = (session as Record | null)?.userId as string | undefined\n const { data: p2pRequests, mutate: mutateP2pRequests } = useSWR(\n session?.accessToken && wttUserId\n ? ['p2p-requests', wttUserId, session.accessToken]\n : null,\n async () => {\n if (!wttUserId) return []\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/for-user?user_id=${encodeURIComponent(wttUserId)}&status=pending`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n const pendingP2pCount = Array.isArray(p2pRequests) ? p2pRequests.length : 0\n\n const topics = useMemo(() => {\n if (!subscribedTopicsRaw || !Array.isArray(subscribedTopicsRaw)) return []\n const humanSender = getHumanSender(session)\n\n const mapped = subscribedTopicsRaw\n .filter((topic: { name: string; description?: string; origin_type?: string; originType?: string; creator_agent_id?: string }) => {\n return !shouldHideFeedTopic(topic as Record)\n })\n .map((topic: { id: string; name: string; description?: string; type?: string; my_role?: string; task_id?: string; runner_agent_id?: string; task_type?: string; task_mode?: string; exec_mode?: string; last_activity_at?: string; creator_agent_id?: string }) => {\n const topicType = ((topic.type || 'discussion').toLowerCase()) as 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n const isDefaultP2P =\n topicType === 'p2p' &&\n !!selectedAgentId &&\n (\n p2pTopicByAgentId[selectedAgentId] === topic.id ||\n (topic.name.includes(selectedAgentId) && topic.name.includes(humanSender))\n )\n\n return {\n topic_id: topic.id,\n name: topic.name,\n topic_type: topicType,\n unread_count: Number((topic as Record).unread_count || 0),\n can_delete: topic.my_role === 'owner' || topic.my_role === 'admin',\n task_id: topic.task_id,\n task_type: topic.task_type ? String(topic.task_type) : undefined,\n task_mode: topic.task_mode ? String(topic.task_mode) : undefined,\n exec_mode: topic.exec_mode ? String(topic.exec_mode) : undefined,\n runner_agent_id: topic.runner_agent_id,\n is_default_p2p: isDefaultP2P,\n last_activity_at: topic.last_activity_at || '',\n description: topic.description,\n creator_agent_id: topic.creator_agent_id,\n }\n })\n\n return mapped.sort((a, b) => {\n // Default P2P always pinned at top\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n // Then sort by most recent activity\n if (a.last_activity_at && b.last_activity_at) {\n const diff = new Date(b.last_activity_at).getTime() - new Date(a.last_activity_at).getTime()\n if (diff !== 0) return diff\n }\n return 0\n })\n }, [subscribedTopicsRaw, selectedAgentId, session, p2pTopicByAgentId])\n\n const subscribedTopicIds = useMemo(() => topics.map(t => t.topic_id), [topics])\n\n const agentItems = useMemo(() => {\n return agents.map((agent) => ({\n agent_id: agent.agent_id,\n display_name: agent.display_name,\n unread_count: 0,\n binding_method: agent.binding_method,\n bound_via: agent.bound_via,\n is_cloud_sandbox: agent.is_cloud_sandbox,\n cloud_host_agent_id: agent.cloud_host_agent_id,\n }))\n }, [agents])\n\n const selectedTopic = topics.find((t) => t.topic_id === selectedTopicId)\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const name = typing.agentName || agentNameMap[typing.agentId] || typing.agentId || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n agentId: typing.agentId,\n agentName: name,\n adapter: typing.adapter,\n model: typing.model,\n wsState,\n statusText: typing.statusText || '等待 Agent 状态更新',\n statusKind: typing.statusKind,\n startedAt: typing.startedAt,\n lines,\n }\n }, [selectedTopicId, typingByTopic, agentNameMap, wsState])\n\n // Clear stale persisted topic if it no longer exists in the topics list\n useEffect(() => {\n if (selectedTopicId && Array.isArray(subscribedTopicsRaw) && !topics.some(t => t.topic_id === selectedTopicId)) {\n setSelectedTopicId(null)\n }\n }, [topics, selectedTopicId, setSelectedTopicId, subscribedTopicsRaw])\n\n const selectedTopicTaskHint = useMemo(() => {\n const direct = selectedTopic?.task_id\n if (direct) return direct\n\n const name = String(selectedTopic?.name || '')\n const match = /^TASK-([a-f0-9]{8})\\b/i.exec(name)\n return match ? match[1].toLowerCase() : undefined\n }, [selectedTopic?.task_id, selectedTopic?.name])\n\n const shouldShowDiscussMembers = !!selectedTopic && ['discussion', 'collaborative'].includes(selectedTopic.topic_type) && !selectedTopicTaskHint\n const { data: topicMembersRaw, mutate: mutateMembers } = useSWR(\n shouldShowDiscussMembers && selectedTopicId && session?.accessToken\n ? ['topic-members', selectedTopicId, session.accessToken]\n : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return []\n return response.json()\n },\n { refreshInterval: 0, revalidateOnFocus: false, dedupingInterval: 30000 }\n )\n\n const topicMembers = useMemo(() => {\n if (!Array.isArray(topicMembersRaw)) return []\n return topicMembersRaw\n .map((m) => (m && typeof m === 'object' ? (m as Record) : null))\n .filter(Boolean)\n .map((m) => {\n const row = m as Record\n const agentId = String(row.agent_id || '')\n const rawRoleTemplate = row.role_template && typeof row.role_template === 'object'\n ? row.role_template as Record\n : undefined\n const rawRoleId = String(row.role_template_id || rawRoleTemplate?.id || '').trim()\n const role = rawRoleId || rawRoleTemplate\n ? roleTemplateFromPayload(rawRoleId || undefined, rawRoleTemplate)\n : agentRoleTemplateMap[agentId] || getAgentRoleTemplate(agentRoleMap[agentId])\n return {\n agent_id: agentId,\n display_name: String(row.display_name || row.agent_id || ''),\n roleLabel: agentRoleDisplayLabel(role),\n }\n })\n .filter((m) => m.agent_id)\n }, [agentRoleMap, agentRoleTemplateMap, topicMembersRaw])\n\n const discussMemberCount = useMemo(() => topicMembers.length, [topicMembers])\n\n // Recent tasks for sidebar shortcuts\n const { data: recentTasksRaw, mutate: mutateRecentTasks } = useSWR(\n session?.accessToken ? ['recent-tasks', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/tasks?limit=50&sort=updated_at&order=desc`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return []\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 60000 : 15000, revalidateOnFocus: true, dedupingInterval: 5000 }\n )\n\n const selectedTopicTaskId = useMemo(() => {\n if (!selectedTopicTaskHint) return undefined\n if (selectedTopicTaskHint.length > 8) return selectedTopicTaskHint\n if (!Array.isArray(recentTasksRaw)) return undefined\n\n for (const item of recentTasksRaw) {\n const raw = item as Record\n const id = String(raw.id || '')\n if (id.toLowerCase().startsWith(selectedTopicTaskHint)) return id\n }\n return undefined\n }, [selectedTopicTaskHint, recentTasksRaw])\n\n // Build sub-agent map: each task = 1 sub-agent, grouped by owner agent\n const agentSubAgents = useMemo(() => {\n const map: Record = {}\n if (Array.isArray(recentTasksRaw)) {\n for (const t of recentTasksRaw) {\n const raw = t as Record\n if (!raw || raw.status === 'cancelled') continue\n const agentId = String(raw.owner_agent_id || raw.runner_agent_id || '')\n if (!agentId) continue\n if (!map[agentId]) map[agentId] = []\n map[agentId].push({\n id: String(raw.id || ''),\n title: String(raw.title || 'Untitled'),\n task_type: String(raw.task_type || 'general'),\n status: String(raw.status || 'todo'),\n })\n }\n }\n return map\n }, [recentTasksRaw])\n\n // Fetch real agent capacity & stats from backend\n const { data: agentStatsRaw, mutate: mutateAgentStats } = useSWR(\n session?.accessToken ? ['agent-stats', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 10000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n const maxSubAgents = (agentStatsRaw as Record)?.max_sub_agents as number | undefined ?? 20\n const agentStats = (agentStatsRaw as Record)?.agents as Record | undefined\n const agentRuntimeMap = useMemo(\n () => (((agentStatsRaw as Record)?.runtimes || {}) as Record),\n [agentStatsRaw]\n )\n const { data: cloudAgentStateRaw, mutate: mutateCloudAgentState } = useSWR(\n session?.accessToken ? ['cloud-agent-state', session.accessToken] : null,\n async () => {\n const r = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n cache: 'no-store',\n })\n if (!r.ok) return null\n return r.json()\n },\n { refreshInterval: wsState === 'connected' ? 15000 : 5000, revalidateOnFocus: true, dedupingInterval: 3000 }\n )\n\n const handleSidebarRefresh = useCallback(async () => {\n await Promise.allSettled([\n loadAgents(),\n mutateTopics(),\n mutateAgentStats(),\n mutateCloudAgentState(),\n mutateP2pRequests(),\n mutateRecentTasks(),\n ])\n }, [loadAgents, mutateAgentStats, mutateCloudAgentState, mutateP2pRequests, mutateRecentTasks, mutateTopics])\n\n useEffect(() => {\n if (!selectedAgentId) return\n void mutateAgentStats()\n void mutateCloudAgentState()\n }, [mutateAgentStats, mutateCloudAgentState, selectedAgentId])\n\n const { data: billingRaw } = useSWR(\n session?.accessToken ? ['billing-me', session.accessToken] : null,\n async () => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${session?.accessToken}` },\n })\n if (!response.ok) return null\n return response.json() as Promise\n },\n { refreshInterval: 5 * 60_000, revalidateOnFocus: false, dedupingInterval: 60_000 }\n )\n const planLabel = useMemo(() => {\n const plan = String(billingRaw?.entitlement?.plan || 'free').toLowerCase()\n if (plan === 'pro') return 'Pro'\n return 'Free'\n }, [billingRaw?.entitlement?.plan])\n const hasCloudAgentRecord = useMemo(\n () => agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n )),\n [agents],\n )\n const cloudAgentNeedsRenewal = hasCloudAgentRecord && String(billingRaw?.entitlement?.plan || 'free').toLowerCase() !== 'pro'\n const sleepingCloudHostIds = useMemo(() => {\n const state = (cloudAgentStateRaw || {}) as CloudAgentState\n const status = String(state.status || '').toLowerCase()\n const hostAgentId = String(state.host_agent_id || state.agent_id || '').trim()\n if (!hostAgentId || !['stopping', 'sleeping', 'stopped'].includes(status)) return new Set()\n return new Set([hostAgentId])\n }, [cloudAgentStateRaw])\n const suppressedCloudAgentIds = useMemo(() => {\n const ids = new Set(Array.from(sleepingCloudHostIds))\n if (ids.size === 0) return ids\n for (const agent of agents) {\n const hostId = String(agent.cloud_host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agent.agent_id)\n if (sleepingCloudHostIds.has(agent.agent_id)) ids.add(agent.agent_id)\n }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n const hostId = String(runtime.host_agent_id || '').trim()\n if (hostId && sleepingCloudHostIds.has(hostId)) ids.add(agentId)\n }\n return ids\n }, [agentRuntimeMap, agents, sleepingCloudHostIds])\n const selectedAgent = selectedAgentId ? agents.find((agent) => agent.agent_id === selectedAgentId) : undefined\n const selectedAgentRuntime = selectedAgentId ? agentRuntimeMap?.[selectedAgentId] : undefined\n const selectedAgentIsCloud = Boolean(selectedAgent?.is_cloud_sandbox)\n || String(selectedAgentRuntime?.provider || '').includes('cloudflare_sandbox')\n const onlineAgentIds = useMemo(() => {\n const arr = (agentStatsRaw as Record)?.online_agents as string[] | undefined\n const ids = new Set(arr ?? [])\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (typeof runtime.last_heartbeat_secs_ago === 'number' && runtime.last_heartbeat_secs_ago <= 90) {\n ids.add(agentId)\n }\n }\n for (const agentId of Array.from(suppressedCloudAgentIds)) {\n ids.delete(agentId)\n }\n return ids\n }, [agentStatsRaw, agentRuntimeMap, suppressedCloudAgentIds])\n\n const submitAgentOperation = useCallback(async (\n operationType: 'cloud_agent_create' | 'cloud_sandbox_clone' | 'local_host_clone',\n payload: Record,\n idempotencyKey?: string,\n ): Promise => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n const { response: createRes, data: createData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },\n body: JSON.stringify({\n operation_type: operationType,\n idempotency_key: idempotencyKey,\n payload,\n }),\n })\n if (!createRes.ok) {\n throw new Error(responseErrorMessage(createData, `Agent operation failed (${createRes.status})`))\n }\n const jobId = String((createData as AgentOperationJob).job_id || '').trim()\n if (!jobId) throw new Error('Agent operation did not return a job id')\n let job = createData as AgentOperationJob\n const startedAt = Date.now()\n while (Date.now() - startedAt < 240_000) {\n const status = String(job.status || '').toLowerCase()\n if (status === 'succeeded') return job\n if (status === 'failed' || status === 'timeout' || status === 'cancelled') {\n throw new Error(job.error_message || `Agent operation ${status}`)\n }\n await delay(1500)\n const { response: pollRes, data: pollData } = await fetchJsonWithTimeout(`${CLIENT_WTT_API_BASE}/agent-operations/${encodeURIComponent(jobId)}`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (!pollRes.ok) {\n throw new Error(responseErrorMessage(pollData, `Agent operation polling failed (${pollRes.status})`))\n }\n job = pollData as AgentOperationJob\n }\n throw new Error('Agent operation is still running; refresh the Agent list to check progress.')\n }, [session?.accessToken, t])\n\n const handleNewAgentFromHost = useCallback(async (hostAgentId: string, role: AgentRoleTemplate, requestedAdapter?: WttConnectAdapter) => {\n const token = session?.accessToken as string | undefined\n if (!token) throw new Error(t('settings.sessionExpired'))\n\n const runtime = agentRuntimeMap[hostAgentId]\n const hostAdapter = normalizeWttConnectAdapter(runtime?.adapter || runtime?.kind)\n if (!hostAdapter) {\n throw new Error('Clone Agent only supports online codex / claude-code / gemini hosts')\n }\n const adapter = requestedAdapter || hostAdapter\n const hostAgent = agents.find((agent) => agent.agent_id === hostAgentId)\n const hostIsCloudSandbox = Boolean(hostAgent?.is_cloud_sandbox)\n || String(hostAgent?.cloud_host_agent_id || '').trim().length > 0\n || String(runtime?.provider || '').toLowerCase().includes('cloudflare_sandbox')\n || String(runtime?.host_agent_id || '').trim().length > 0\n\n const displayName = role.id === 'general'\n ? `${adapter} Agent`\n : (role.shortLabel || role.label || `${adapter} Agent`)\n\n const modelId = hostIsCloudSandbox && adapter === hostAdapter\n ? String(runtime?.current_model || runtime?.model_id || runtime?.model || '').trim()\n : ''\n const job = await submitAgentOperation(\n hostIsCloudSandbox ? 'cloud_sandbox_clone' : 'local_host_clone',\n {\n host_agent_id: hostAgentId,\n adapter,\n display_name: displayName,\n model_id: modelId || undefined,\n role_template_id: role.id === 'general' ? '' : role.id,\n role_template: role.id === 'general' ? {} : serializeAgentRoleTemplate(role),\n client_operation_id: newClientOperationId(),\n },\n )\n const newAgentId = String(job.result?.agent_id || '').trim()\n if (!newAgentId) {\n throw new Error('Clone operation succeeded but did not return agent_id')\n }\n\n await loadAgents()\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Clone Agent started: ${newAgentId}`)\n }, [agentRuntimeMap, agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const handleCreateCloudAgent = useCallback(async (options?: CloudAgentCreateOptions) => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n try {\n const billingRes = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n })\n if (billingRes.ok) {\n const billing = await billingRes.json().catch(() => ({}))\n const plan = String((billing as { entitlement?: { plan?: unknown } }).entitlement?.plan || 'free')\n const alreadyHasCloudAgent = agents.some((agent) => (\n (agent.binding_method || agent.bound_via || '') === 'cloud_trial'\n || agent.is_cloud_sandbox\n || Boolean(agent.cloud_host_agent_id)\n ))\n if (alreadyHasCloudAgent && plan !== 'pro') {\n alert('你已经创建过 Cloud Agent,但当前 Pro 会员已到期。请到 设置中心 > 账户升级 续费后继续使用。')\n setForceOpenSettingsPage('membership')\n return\n }\n if (alreadyHasCloudAgent) {\n alert('该账号已经创建过 Cloud Agent,每个账号只能创建一个。')\n return\n }\n if (plan !== 'pro') {\n alert('Cloud Agent 需要升级为 Pro 账户后才能使用。请到 设置中心 > 账户升级 开通。')\n setForceOpenSettingsPage('membership')\n return\n }\n } else {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n } catch {\n alert('暂时无法校验会员状态,请稍后重试。')\n return\n }\n const adapterLabel = options?.adapter === 'codex' ? 'Codex' : options?.adapter === 'gemini' ? 'Gemini' : 'DeepSeek + Claude Code'\n const accepted = window.confirm([\n 'Cloud Agent 会在 Cloudflare Sandbox 中运行。',\n '每个用户只有一个 Cloud Sandbox;需要更多 Agent 请使用 Clone Agent,它们会运行在同一个 Sandbox 中。',\n `默认运行时:${adapterLabel}`,\n options?.adapter === 'claude-code' ? 'DeepSeek + Claude Code 每小时额外 ¥0.5;Pro 额度为 30 次连续请求、本月共 500 次。' : 'API Key 可选;不填可创建后在 Terminal 中配置/登录。',\n 'Agent 运行在隔离 Sandbox 中,workspace 独立但不建议存放敏感信息。',\n '请勿进行挖矿、攻击、扫描、绕过限制等恶意操作,违规会封号。',\n '',\n '确认创建 Cloud Agent?',\n ].filter(Boolean).join('\\n'))\n if (!accepted) return\n\n try {\n const job = await submitAgentOperation(\n 'cloud_agent_create',\n {\n accepted_terms: true,\n display_name: options?.displayName || 'Cloud Agent',\n agent_type: options?.adapter || 'claude-code',\n adapter: options?.adapter || 'claude-code',\n provider_plan: options?.providerPlan || 'deepseek',\n default_model: options?.model || 'deepseek-v4-pro[1m]',\n model: options?.model || 'deepseek-v4-pro[1m]',\n api_key: options?.apiKey || undefined,\n llm_api_key: options?.apiKey || undefined,\n pricing_addon_rmb_per_hour: options?.adapter === 'claude-code' ? 0.5 : 0,\n },\n 'cloud-agent-create',\n )\n\n const newAgentId = String(job.result?.agent_id || '').trim()\n await loadAgents()\n if (newAgentId) {\n setSelectedAgentId(newAgentId)\n setSelectedTopicId(null)\n }\n void mutateTopics()\n window.setTimeout(() => {\n void loadAgents()\n void mutateTopics()\n }, 2500)\n alert(`Cloud Agent created: ${newAgentId || 'success'}`)\n } catch (error) {\n alert(error instanceof Error ? error.message : 'Cloud Agent failed')\n }\n }, [agents, loadAgents, mutateTopics, session?.accessToken, setSelectedAgentId, setSelectedTopicId, submitAgentOperation, t])\n\n const runCloudSandboxAction = useCallback(async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n\n const cleanHostAgentId = String(hostAgentId || '').trim()\n if (!cleanHostAgentId) {\n alert('Cloud Sandbox host agent_id is missing')\n return\n }\n\n if (action === 'sleep') {\n const accepted = window.confirm([\n '确认关机这个 Cloud Sandbox?',\n '系统会允许 Cloudflare 在短暂空闲后停止计费,之后可从同一菜单开机。',\n ].join('\\n'))\n if (!accepted) return\n }\n\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/${encodeURIComponent(cleanHostAgentId)}/${action}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${token}` },\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(responseErrorMessage(data, `Cloud Sandbox ${action} failed (${res.status})`))\n }\n void mutateCloudAgentState((prev: CloudAgentState | null | undefined) => ({\n ...(prev || {}),\n has_cloud_agent: true,\n agent_id: cleanHostAgentId,\n status: action === 'wake' ? 'waking' : 'stopping',\n provider: (prev || {}).provider || 'cloudflare_sandbox',\n }), false)\n\n const deadline = Date.now() + (action === 'wake' ? 300000 : 180000)\n let lastStatus = ''\n let lastOnline = false\n let lastMissingAgents: string[] = []\n let lastExpectedAgents: string[] = [cleanHostAgentId]\n while (Date.now() < deadline) {\n const [stateResp, statsResp] = await Promise.all([\n fetch(`${CLIENT_WTT_API_BASE}/cloud-agents/me?live=false`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, {\n headers: { Authorization: `Bearer ${token}` },\n cache: 'no-store',\n }),\n ])\n const state = await stateResp.json().catch(() => ({}))\n const stats = await statsResp.json().catch(() => ({}))\n lastStatus = String((state as { status?: unknown }).status || '').toLowerCase()\n const online = (stats as { online_agents?: unknown }).online_agents\n const onlineIds = new Set(Array.isArray(online) ? online.map(String) : [])\n const expectedIds = new Set(cloudSandboxExpectedAgentIds(state as CloudAgentState, cleanHostAgentId))\n for (const agent of agents) {\n if (String(agent.cloud_host_agent_id || '').trim() === cleanHostAgentId || agent.agent_id === cleanHostAgentId) {\n expectedIds.add(agent.agent_id)\n }\n }\n lastExpectedAgents = Array.from(expectedIds)\n lastMissingAgents = lastExpectedAgents.filter((agentId) => !onlineIds.has(agentId))\n lastOnline = lastExpectedAgents.length > 0 && lastMissingAgents.length === 0\n\n void mutateAgentStats(stats, false)\n void mutateCloudAgentState(state as CloudAgentState, false)\n if (action === 'wake' && lastStatus === 'running' && lastOnline) break\n if (action === 'sleep' && ['stopped', 'sleeping'].includes(lastStatus)) break\n await delay(3000)\n }\n\n const completed = action === 'wake'\n ? lastStatus === 'running' && lastOnline\n : ['stopped', 'sleeping'].includes(lastStatus)\n if (!completed) {\n throw new Error(action === 'wake'\n ? `Sandbox 仍在开机中,最后状态:${lastStatus || 'unknown'},已等待 ${lastExpectedAgents.length} 个 Agent,未上线:${lastMissingAgents.slice(0, 6).join(', ') || 'unknown'}`\n : `Sandbox 仍在关机中,最后状态:${lastStatus || 'unknown'}`)\n }\n\n await loadAgents()\n void mutateAgentStats()\n void mutateCloudAgentState()\n void mutateTopics()\n alert(action === 'wake' ? `Cloud Sandbox 已开机,${lastExpectedAgents.length} 个 Agent 已在线。` : 'Cloud Sandbox 已关机。')\n } catch (error) {\n alert(error instanceof Error ? error.message : `Cloud Sandbox ${action} failed`)\n }\n }, [agents, loadAgents, mutateAgentStats, mutateCloudAgentState, mutateTopics, session?.accessToken, t])\n\n const handleSleepSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'sleep')\n }, [runCloudSandboxAction])\n\n const handleWakeSandbox = useCallback((hostAgentId: string) => {\n return runCloudSandboxAction(hostAgentId, 'wake')\n }, [runCloudSandboxAction])\n\n useEffect(() => {\n setMembersOpen(false)\n }, [selectedTopicId])\n\n // Auto-create P2P topic for each claimed agent (if not exists)\n const p2pInitRef = useRef(new Set())\n useEffect(() => {\n if (!selectedAgentId || !session?.accessToken || !topics) return\n const humanSender = getHumanSender(session)\n for (const agent of agents) {\n const aid = agent.agent_id\n if (p2pInitRef.current.has(aid)) continue\n const existingMappedTopicId = p2pTopicByAgentId[aid]\n const hasP2p = Boolean(existingMappedTopicId && topics.some(t => t.topic_id === existingMappedTopicId)) ||\n topics.some(t => t.topic_type === 'p2p' && t.name.includes(aid) && t.name.includes(humanSender))\n if (hasP2p) { p2pInitRef.current.add(aid); continue }\n p2pInitRef.current.add(aid)\n // Silently create P2P topic — no visible system message\n fetch(`${CLIENT_WTT_API_BASE}/messages/p2p?sender_id=${encodeURIComponent(humanSender)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({ target_agent_id: aid, content: '[system:p2p_init]', content_type: 'text', semantic_type: 'system' }),\n }).then(async (response) => {\n const payload = await response.json().catch(() => ({}))\n const topicId = String((payload as { topic_id?: unknown }).topic_id || '').trim()\n if (response.ok && topicId) {\n setP2pTopicByAgentId((prev) => ({ ...prev, [aid]: topicId }))\n }\n await mutateTopics()\n }).catch(() => {})\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agents, topics, selectedAgentId, session?.accessToken, p2pTopicByAgentId])\n\n useEffect(() => {\n if (!selectedAgentId || selectedTopicId) return\n const topicId = p2pTopicByAgentId[selectedAgentId]\n if (!topicId) return\n if (topics.some((topic) => topic.topic_id === topicId)) {\n setSelectedTopicId(topicId)\n }\n }, [p2pTopicByAgentId, selectedAgentId, selectedTopicId, setSelectedTopicId, topics])\n\n const searchParams = useSearchParams()\n\n useEffect(() => {\n const settingsFromUrl = (searchParams.get('settings') || '').toLowerCase()\n if (settingsFromUrl === 'profile') {\n setForceOpenSettingsPage('profile')\n } else if (settingsFromUrl === 'binding') {\n setForceOpenSettingsPage('binding')\n }\n }, [searchParams])\n\n useEffect(() => {\n const topicFromUrl = searchParams.get('topicId') || searchParams.get('topic')\n if (!topicFromUrl) return\n if (topics.some((t) => t.topic_id === topicFromUrl)) {\n setSelectedTopicId(topicFromUrl)\n }\n }, [topics, searchParams, setSelectedTopicId])\n\n useEffect(() => {\n if (!pendingComposerFocusTopicId) return\n if (selectedTopicId !== pendingComposerFocusTopicId) return\n if (!topics.some((t) => t.topic_id === pendingComposerFocusTopicId)) return\n\n setComposerFocusNonce((v) => v + 1)\n const retry = window.setTimeout(() => setComposerFocusNonce((v) => v + 1), 220)\n setPendingComposerFocusTopicId(null)\n return () => window.clearTimeout(retry)\n }, [pendingComposerFocusTopicId, selectedTopicId, topics])\n\n const handleCreateGeneralTask = useCallback(async () => {\n if (!selectedAgentId || !session?.accessToken) return\n try {\n const resp = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${session.accessToken}`,\n },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: getHumanSender(session),\n }),\n })\n\n if (!resp.ok) {\n alert(t('feed.failedCreateTask'))\n return\n }\n\n const real = await resp.json()\n mutateRecentTasks()\n mutateTopics()\n\n const topicId = String(real?.topic_id || '')\n if (topicId) {\n setSelectedTopicId(topicId)\n setPendingComposerFocusTopicId(topicId)\n } else {\n router.push(buildAgentUrl('/tasks', selectedAgentId, { type: 'general' }))\n }\n } catch {\n alert(t('feed.failedCreateTask'))\n }\n }, [selectedAgentId, session, mutateRecentTasks, mutateTopics, router, t, setSelectedTopicId])\n\n const handleSendMessage = async (content: string, modelConfig?: ChatModelConfig, replyTo?: string, options?: ChatSendOptions) => {\n if (!selectedTopicId || !selectedAgentId) return\n\n const topicIdForSend = selectedTopicId\n const agentIdForSend = selectedAgentId\n const baselineAgentMessageIds = new Set(\n allMessages\n .filter((m) => m.sender_type === 'agent' && (!agentIdForSend || m.sender_id === agentIdForSend))\n .map((m) => m.message_id),\n )\n typingBaselineAgentMessageIdsRef.current[topicIdForSend] = baselineAgentMessageIds\n setTypingByTopic((prev) => {\n const now = Date.now()\n return {\n ...prev,\n [topicIdForSend]: appendTypingStatus(prev[topicIdForSend], {\n agentId: agentIdForSend,\n agentName: agentNameMap[agentIdForSend] || undefined,\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n ttlMs: AGENT_TYPING_STALE_MS,\n }, now),\n }\n })\n\n const isTask = !!selectedTopicTaskId\n const isSlashCommand = content.trim().startsWith('/')\n const isNonTaskDiscuss = selectedTopic?.topic_type === 'discussion' && !isTask\n\n // Build metadata with model config so the agent knows which model/mode to use\n const metadata: Record = {}\n if (modelConfig) {\n metadata.model_config = {\n model: modelConfig.model,\n reasoning_effort: modelConfig.reasoningEffort,\n }\n }\n const selectedCloudAgentOnline = selectedAgentId ? onlineAgentIds.has(selectedAgentId) : false\n if (selectedAgentIsCloud && selectedCloudAgentOnline) {\n metadata.cloud_no_auto_wake = true\n }\n if (options?.slashType || isSlashCommand) {\n metadata.slash_type = options?.slashType || 'agent_passthrough'\n metadata.slash_command = options?.slashCommand || content.trim().split(/\\s+/, 1)[0] || content.trim()\n }\n\n if (isSlashCommand && isNonTaskDiscuss) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = selectedAgentId\n }\n\n // Keep user-visible messages clean. Role/persona context is carried as\n // metadata so the agent runner can use it as hidden \"soul\" context.\n const ws = activeWorkerSessionRef.current\n const augmentedContent = content\n if (ws && ws.topicId === selectedTopicId && (ws.isFirstSession || ws.personaChanged) && ws.personaMd) {\n metadata.worker_persona = {\n worker_id: ws.workerId,\n persona_md: ws.personaMd,\n changed: Boolean(ws.personaChanged && !ws.isFirstSession),\n }\n // Mark as no longer first session / persona change handled\n activeWorkerSessionRef.current = { ...ws, isFirstSession: false, personaChanged: false }\n // Persist worker.md with persona content so future sessions have context\n // Also updates persona_hash on backend to mark injection done\n fetch(`${CLIENT_WTT_API_BASE}/workers/${ws.workerId}/worker-md`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({ worker_md: ws.personaMd }),\n }).catch(() => {})\n } else if (ws && ws.topicId === selectedTopicId && ws.workerMd) {\n // Subsequent session — send worker.md as hidden context if messages are empty.\n if (allMessages.length === 0) {\n metadata.worker_context = {\n worker_id: ws.workerId,\n worker_md: ws.workerMd,\n }\n }\n }\n\n if (isTask && selectedTopicTaskId) {\n // Keep auto_run enabled for task chat sends from Feed.\n // Backend only transitions on first send when task.status == 'todo',\n // so this is safe for doing/review/done tasks and avoids stale-status misses.\n const sendResp = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${selectedTopicTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session?.accessToken ?? ''}` },\n body: JSON.stringify({\n content: augmentedContent,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }),\n })\n // Force topic list refresh so auto-renamed title appears immediately\n if (sendResp.ok) {\n pendingRenameTaskRef.current = null\n await mutateTopics()\n }\n } else {\n // Regular topic — use publishMessage (may include worker persona context)\n let outboundContent = augmentedContent\n let encrypted = false\n if (P2P_E2E_WEB_ENABLED && selectedTopic?.topic_type === 'p2p') {\n const messageId = `web-p2p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n const enc = await encryptForSend(augmentedContent, messageId)\n outboundContent = enc.content\n encrypted = enc.encrypted\n }\n\n await wttApi.publishMessage(selectedTopicId, {\n content: outboundContent,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n ...(encrypted ? { encrypted: true } : {}),\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(Object.keys(metadata).length > 0 ? { metadata } : {}),\n }, {\n agentId: selectedAgentId || undefined,\n })\n }\n\n // Optimistically bump topic to top of activity sort\n if (subscribedTopicsRaw && Array.isArray(subscribedTopicsRaw)) {\n const now = new Date().toISOString()\n mutateTopics(\n subscribedTopicsRaw.map((t) => {\n const rec = t as Record\n return rec.id === selectedTopicId ? { ...rec, last_activity_at: now } : t\n }),\n false\n )\n }\n\n mutate()\n }\n\n const exportPlaintextTopicMarkdown = async () => {\n if (!selectedTopicId) return\n const token = session?.accessToken\n if (!token) {\n alert('Session expired. Please re-login and try again.')\n return\n }\n\n const pageSize = 500\n const maxPages = 60\n let offset = 0\n const rows: ChatMessage[] = []\n\n for (let page = 0; page < maxPages; page++) {\n const params = new URLSearchParams({\n limit: String(pageSize),\n offset: String(offset),\n })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n throw new Error(`Export fetch failed: HTTP ${res.status}`)\n }\n\n const batchRaw = await res.json() as unknown\n if (!Array.isArray(batchRaw) || batchRaw.length === 0) break\n\n const normalized = normalizeFeed(batchRaw, knownAgentIds)\n const decrypted = await decryptMessagesForDisplay(normalized)\n rows.push(...decrypted)\n\n if (batchRaw.length < pageSize) break\n offset += batchRaw.length\n }\n\n if (rows.length === 0) {\n alert('No messages to export in this topic.')\n return\n }\n\n const sorted = [...rows].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())\n const unresolved = sorted.filter((m) => m.content.includes('[🔒'))\n\n const header = [\n `# ${selectedTopic?.name || `Topic ${selectedTopicId}`}`,\n '',\n `- Topic ID: \\`${selectedTopicId}\\``,\n `- Type: \\`${selectedTopic?.topic_type || 'unknown'}\\``,\n `- Exported At: \\`${new Date().toISOString()}\\``,\n `- Export Mode: \\`client-side plaintext\\``,\n '',\n '---',\n '',\n ]\n\n const body: string[] = []\n for (const m of sorted) {\n const sender = m.sender_display_name || m.sender_id || 'unknown'\n body.push(`## ${m.timestamp} · ${sender}`)\n body.push('')\n body.push(m.content || '')\n body.push('')\n }\n\n const markdown = [...header, ...body].join('\\n')\n const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })\n const url = URL.createObjectURL(blob)\n const a = document.createElement('a')\n const baseName = (selectedTopic?.name || `topic_${selectedTopicId}`).replace(/[\\\\/:*?\"<>|]+/g, '_')\n a.href = url\n a.download = `${baseName}_plaintext.md`\n document.body.appendChild(a)\n a.click()\n a.remove()\n URL.revokeObjectURL(url)\n\n if (unresolved.length > 0) {\n alert(`Export completed with ${unresolved.length} locked/decrypt-failed messages.`)\n }\n }\n\n const handleExportTopic = async (format: 'md') => {\n if (!selectedTopicId) return\n\n // For P2P E2E topics, export plaintext on client side.\n if (format === 'md' && selectedTopic?.topic_type === 'p2p') {\n try {\n await exportPlaintextTopicMarkdown()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Plaintext export failed')\n }\n return\n }\n\n const u = `${CLIENT_WTT_API_BASE}/export/topic/${selectedTopicId}?format=${format}`\n window.open(u, '_blank', 'noopener,noreferrer')\n }\n\n const handleRenameAgent = async (agentId: string, currentName: string) => {\n const next = prompt('New agent name', currentName)\n if (!next || next.trim() === currentName) return\n try {\n await wttApi.renameAgent(agentId, next.trim())\n await loadAgents()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Rename failed')\n }\n }\n\n const handleUnclaimAgent = async (agentId: string) => {\n if (!confirm(`Unclaim agent ${agentId}?`)) return\n try {\n const token = session?.accessToken as string | undefined\n if (!token) {\n alert(t('settings.sessionExpired'))\n return\n }\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(agentId)}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${token}` },\n })\n if (!res.ok) {\n const data = await res.json().catch(() => ({ detail: 'Unknown error' }))\n throw new Error(data.detail || `HTTP ${res.status}`)\n }\n await loadAgents()\n await mutateTopics()\n alert(t('feed.agentUnclaimed'))\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Unclaim failed')\n }\n }\n\n const handleLeaveTopic = async (topicId: string) => {\n if (!confirm('Leave this topic?')) return\n try {\n const wsResult = await sendAction('leave', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.leaveTopic(topicId, selectedAgentId)\n }\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Leave topic failed')\n }\n }\n\n const handleDeleteTopic = async (topicId: string) => {\n if (!confirm('Delete this topic? (soft delete)')) return\n try {\n await wttApi.deleteTopic(topicId, selectedAgentId)\n if (selectedTopicId === topicId) setSelectedTopicId(null)\n await mutateTopics()\n } catch (err) {\n alert(err instanceof Error ? err.message : 'Delete topic failed')\n }\n }\n\n const handleSubscribeTopic = async (topicId: string) => {\n if (!selectedAgentId || !session?.accessToken) return\n const wsResult = await sendAction('join', { topic_id: topicId })\n if (wsResult === null) {\n await wttApi.joinTopic(topicId, selectedAgentId)\n }\n await mutateTopics()\n }\n\n const handleCreateP2P = async (targetAgentId: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n // Send a P2P request instead of directly creating a topic\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n message: `P2P chat request from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send P2P request')\n }\n alert(t('feed.p2pRequestSent'))\n }\n\n const handleRequestDiscuss = async (targetAgentId: string, topicName: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: topicName,\n message: `Discussion topic invite from ${humanSender}`,\n }),\n })\n if (!res.ok) {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n throw new Error(err.detail || 'Failed to send discuss request')\n }\n alert(t('feed.discussRequestSent'))\n }\n\n const handleInviteMember = async (agentId: string) => {\n if (!session?.accessToken || !selectedTopicId) return\n setInvitingMember(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n mutateMembers()\n setInviteAgentId('')\n setInviteMemberOpen(false)\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n } finally {\n setInvitingMember(false)\n }\n }\n\n const handleSelectWorkerTopic = (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => {\n if (workerSession) {\n activeWorkerSessionRef.current = { ...workerSession, personaChanged: workerSession.personaChanged ?? false, topicId }\n }\n mutateTopics().then(() => {\n setSelectedTopicId(topicId)\n })\n }\n\n const handleRequestPrivateDiscuss = async (targetAgentId: string, targetDisplayName?: string) => {\n if (!session?.accessToken) return\n const humanSender = getHumanSender(session)\n const fromUserId = wttUserId || humanSender\n const targetName = targetDisplayName || targetAgentId\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },\n body: JSON.stringify({\n from_user_id: fromUserId,\n from_agent_id: selectedAgentId,\n target_agent_id: targetAgentId,\n request_type: 'discuss',\n topic_name: `${humanSender} & ${targetName}`,\n message: `Private discuss request from ${humanSender}`,\n }),\n })\n if (res.ok) {\n alert(t('feed.privateDiscussSent'))\n } else {\n const err = await res.json().catch(() => ({ detail: 'Failed' }))\n alert(err.detail || t('feed.privateDiscussFailed'))\n }\n } catch {\n alert(t('feed.networkError'))\n }\n }\n\n const handleAcceptP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n const res = await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/accept`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n if (res.ok) {\n const data = await res.json()\n await mutateTopics()\n await mutateP2pRequests()\n if (data.topic_id) setSelectedTopicId(data.topic_id)\n }\n }\n\n const handleRejectP2PRequest = async (requestId: string) => {\n if (!session?.accessToken) return\n await fetch(`${CLIENT_WTT_API_BASE}/p2p-requests/${requestId}/reject`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${session.accessToken}` },\n })\n await mutateP2pRequests()\n }\n\n const handleEditorPublish = async (topicId: string, content: string, format: ContentFormat = 'markdown') => {\n const isHtml = format === 'html'\n const ext = isHtml ? '.html' : '.md'\n const mime = isHtml ? 'text/html' : 'text/markdown'\n const filename = `post-${Date.now()}${ext}`\n const blob = new Blob([content], { type: mime })\n\n const signRes = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, mime_type: mime, size: blob.size }),\n })\n if (!signRes.ok) throw new Error(await signRes.text())\n const signed = await signRes.json()\n\n const uploadRes = await fetch(`${CLIENT_WTT_API_BASE}${signed.upload_url}`, {\n method: 'PUT',\n headers: { 'Content-Type': mime },\n body: blob,\n })\n if (!uploadRes.ok) throw new Error(await uploadRes.text())\n\n const commitRes = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commitRes.ok) throw new Error(await commitRes.text())\n const asset = await commitRes.json()\n\n // Build message: short plain-text preview + file link\n const stripped = isHtml\n ? content.replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()\n : content.replace(/[#*`>_~\\[\\]()!|]/g, '').trim()\n const preview = stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped\n const messageContent = `${preview}\\n\\n[file:${filename}](${asset.url})`\n\n await wttApi.publishMessage(topicId, {\n content: messageContent,\n content_type: 'mixed',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: getHumanSender(session),\n })\n mutate()\n }\n\n const editorTopics = useMemo(\n () => topics.map((t) => ({ topic_id: t.topic_id, name: t.name, topic_type: t.topic_type })),\n [topics],\n )\n\n const handleTopicChange = useCallback((topicId: string | null) => {\n setSelectedTopicId(topicId)\n if (!topicId) return\n\n // Optimistic unread-clear for immediate red badge feedback.\n void mutateTopics((prev: unknown) => {\n if (!Array.isArray(prev)) return prev\n return prev.map((t) => {\n const row = t as Record\n const id = String(row.id ?? row.topic_id ?? '')\n if (id !== topicId) return t\n return { ...row, unread_count: 0 }\n })\n }, false)\n }, [mutateTopics, setSelectedTopicId])\n\n if (status === 'loading') {\n return (\n
\n
\n
\n )\n }\n\n if (status === 'unauthenticated') return null\n\n return (\n <>\n router.push(buildAgentUrl('/discover', selectedAgentId))} />\n\n { setSelectedAgentId(id); setSelectedTopicId(null) }}\n topics={topics}\n selectedTopicId={selectedTopicId}\n onTopicChange={handleTopicChange}\n onRenameAgent={handleRenameAgent}\n onUnclaimAgent={handleUnclaimAgent}\n onLeaveTopic={handleLeaveTopic}\n onDeleteTopic={handleDeleteTopic}\n onSubscribeTopic={handleSubscribeTopic}\n onCreateP2P={handleCreateP2P}\n onRequestDiscuss={handleRequestDiscuss}\n subscribedTopicIds={subscribedTopicIds}\n onOpenEditor={() => setEditorOpen(true)}\n onOpenKnowledgeRoot={handleOpenKnowledgeRoot}\n onCreateGeneralTask={handleCreateGeneralTask}\n onLogout={() => signOut({ callbackUrl: '/login' })}\n onTopicsRefresh={handleSidebarRefresh}\n onBindingChanged={loadAgents}\n notificationCount={pendingP2pCount}\n p2pRequests={Array.isArray(p2pRequests) ? p2pRequests : []}\n onAcceptP2PRequest={handleAcceptP2PRequest}\n onRejectP2PRequest={handleRejectP2PRequest}\n onSelectWorkerTopic={handleSelectWorkerTopic}\n currentUserName={getHumanSender(session)}\n agentSubAgents={agentSubAgents}\n maxSubAgents={maxSubAgents}\n agentStats={agentStats ?? undefined}\n onlineAgentIds={onlineAgentIds}\n agentRoleMap={agentRoleMap}\n agentRoleTemplateMap={agentRoleTemplateMap}\n agentRuntimeMap={agentRuntimeMap}\n onAssignAgentRole={handleAssignAgentRole}\n onSaveAgentRole={handleSaveAgentRole}\n onNewAgentFromHost={handleNewAgentFromHost}\n onCreateCloudAgent={handleCreateCloudAgent}\n onSleepSandbox={handleSleepSandbox}\n onWakeSandbox={handleWakeSandbox}\n userToken={session?.accessToken as string | undefined}\n planLabel={planLabel}\n forceOpenSettingsPage={forceOpenSettingsPage}\n onForceOpenHandled={() => setForceOpenSettingsPage(null)}\n >\n
\n {/* Main content area */}\n
\n {cloudAgentNeedsRenewal && (\n
\n
\n \n 你已经创建过 Cloud Agent,但当前 Pro 会员已到期。续费后可继续使用云端 Agent、技术面试、教育和高考板块。\n \n setForceOpenSettingsPage('membership')}\n className=\"rounded-full border border-amber-300 bg-white px-3 py-1 text-[11px] font-bold text-amber-800 shadow-sm transition hover:bg-amber-100 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100 dark:hover:bg-amber-400/20\"\n >\n 立即续费\n \n
\n
\n )}\n {selectedTopicId && selectedTopic ? (\n
\n !m.content.includes('[system:p2p_init]') && !m.content.includes('[System] P2P channel established'))}\n currentAgentId={selectedAgentId}\n onSendMessage={handleSendMessage}\n onLoadOlder={loadOlderMessages}\n onExport={handleExportTopic}\n hasOlder={hasOlder && !loadingOlder}\n loading={!feedRaw && !error}\n isTaskTopic={!!selectedTopicTaskId}\n taskType={toChatTaskType(selectedTopic.task_type, selectedTopic.task_mode, selectedTopic.exec_mode)}\n wsConnected={wsState === 'connected'}\n accessToken={session?.accessToken as string | undefined}\n onTaskCreated={() => mutateRecentTasks()}\n onTopicCreated={() => mutateTopics()}\n topicMembers={topicMembers}\n topicType={selectedTopic.topic_type}\n runStatus={selectedTopicRunStatus}\n onRequestPrivateDiscuss={handleRequestPrivateDiscuss}\n autoFocusNonce={composerFocusNonce}\n workspaceAgentName={selectedAgentId ? (agentNameMap[selectedAgentId] || selectedAgentId) : undefined}\n workspaceWorkdir={selectedAgentId ? agentRuntimeMap?.[selectedAgentId]?.workdir : undefined}\n currentAgentRuntime={selectedAgentRuntime}\n currentAgentIsCloud={selectedAgentIsCloud}\n cloudSandboxBilling={selectedAgentIsCloud ? {\n ...(((cloudAgentStateRaw as CloudAgentState | null | undefined)?.sandbox_billing) || {}),\n cloud_agent_usage: billingRaw?.cloud_agent_usage,\n entitlement: billingRaw?.entitlement,\n } : null}\n agentRoleLabelMap={agentRoleLabelMap}\n compactUi\n extraHeaderActions={\n shouldShowDiscussMembers ? (\n
\n setMembersOpen((v) => !v)}\n className=\"flex items-center gap-1 rounded border border-slate-200 dark:border-zinc-600 px-1.5 py-0.5 text-[10px] text-slate-500 dark:text-zinc-300 hover:bg-slate-50 dark:hover:bg-zinc-700 hover:text-slate-700 dark:hover:text-zinc-100\"\n title={t('feed.members')}\n >\n 👥 {t('feed.members')} ({discussMemberCount}) ▾\n \n {membersOpen && (\n <>\n
setMembersOpen(false)} />\n
\n {topicMembers.length > 0 ? (\n topicMembers.map((m) => (\n \n ))\n ) : (\n
{t('feed.noMembers')}
\n )}\n {/* Invite member */}\n
\n {inviteMemberOpen ? (\n
\n setInviteAgentId(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim())\n if (e.key === 'Escape') { setInviteMemberOpen(false); setInviteAgentId('') }\n }}\n placeholder={t('feed.agentIdPlaceholder')}\n className=\"flex-1 bg-transparent text-xs text-slate-700 dark:text-zinc-200 placeholder:text-slate-400 outline-none border-b border-indigo-400\"\n />\n { if (inviteAgentId.trim()) handleInviteMember(inviteAgentId.trim()) }}\n disabled={!inviteAgentId.trim() || invitingMember}\n className=\"rounded bg-indigo-500 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-indigo-600 disabled:opacity-50\"\n >\n {invitingMember ? '...' : t('feed.add')}\n \n
\n ) : (\n setInviteMemberOpen(true)}\n className=\"flex items-center gap-1 text-[11px] font-medium text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition\"\n >\n + {t('feed.inviteMember')}\n \n )}\n
\n
\n \n )}\n
\n ) : undefined\n }\n />\n
\n ) : (\n
\n {agents.length === 0 ? (\n
\n

欢迎使用 WTT

\n

\n 开始前需要先添加一个 Agent。请使用左侧 Agent 栏顶部入口,选择“绑定已有 Agent”或“新建 Agent”。\n

\n
\n
\n

绑定已有 Agent

\n

\n 在你自己的电脑、服务器或 Mac mini 上运行 wtt-connect,把本地 Agent 接入 WTT。\n

\n
\n
\n

新建云端 Agent

\n

\n Pro 用户可创建 Cloud Sandbox Agent;后续可在同一 Sandbox 内 Clone 更多 Agent。\n

\n
\n
\n
\n 左侧栏上方的绿色按钮用于绑定已有 Agent,蓝色云按钮用于新建云端 Agent。\n
\n
\n ) : (\n <>\n

{t('feed.selectTopic')}

\n

{t('feed.selectTopicHint')}

\n \n )}\n
\n )}\n
\n\n
\n \n\n {editorOpen && (\n setEditorOpen(false)}\n />\n )}\n \n )\n}\n"}}, "app/mobile/feed/page.tsx": {"da26680f49538a590680ceb96a49ed6ac0f5e222": {"t": 1785747475.7514367, "n": 120199, "h": "831f7819245c2a54", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport {\n isTerminalMobileStatusKind,\n normalizeMobileProgressStatusKind,\n shouldCloseWaitingStatusFromAgentReply,\n shouldPollMobileMessages,\n} from '@/lib/mobile-chat-status'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\nimport {\n mergeMessageHistory,\n readCachedMessageHistory,\n writeCachedMessageHistory,\n} from '@/lib/chat-history'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n task_status?: string\n runner_agent_id?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype MobileRunStatus = {\n agentId: string\n agentName: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n lines: TypingState['statusLines']\n startedAt: number\n expiresAt: number\n wsState: string\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = normalizeMobileProgressStatusKind(group, detail, status)\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('todo')) return '待执行'\n if (normalized.includes('doing') || normalized.includes('running')) return '执行中'\n if (normalized.includes('review')) return '待验收'\n if (normalized.includes('blocked')) return '阻塞'\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction taskStatusText(status?: string, title?: string): string {\n const normalized = String(status || '').toLowerCase()\n const subject = title ? `「${title}」` : '当前任务'\n if (normalized === 'todo') return `${subject}已创建,等待 Agent 接收`\n if (normalized === 'doing') return `${subject}执行中`\n if (normalized === 'review') return `${subject}已完成执行,等待验收`\n if (normalized === 'done') return `${subject}已完成`\n if (normalized === 'blocked') return `${subject}已阻塞`\n if (normalized === 'cancelled') return `${subject}已取消`\n return normalized ? `${subject}状态:${normalized}` : ''\n}\n\nfunction isTerminalStatusKind(kind?: string): boolean {\n return isTerminalMobileStatusKind(kind)\n}\n\nfunction adapterStatusLabel(adapter?: string): string {\n const value = String(adapter || '').trim()\n if (value === 'claude-code') return 'Claude Code'\n if (value === 'codex') return 'Codex'\n if (value === 'gemini') return 'Gemini'\n return value\n}\n\nfunction MobileAgentRunStatusCard({ status, compact = false }: { status: MobileRunStatus; compact?: boolean }) {\n const terminal = isTerminalStatusKind(status.statusKind)\n const lines = status.lines.slice(compact ? -4 : -6)\n const subtitle = [adapterStatusLabel(status.adapter), status.model].filter(Boolean).join(' · ')\n\n return (\n
\n
\n \n
\n
\n {status.agentName}\n \n {mobileStatusKindLabel(status.statusKind)}\n \n {subtitle && {subtitle}}\n WS {status.wsState}\n
\n

{status.statusText}

\n
\n
\n {lines.length > 0 && (\n
\n {lines.map((line) => (\n
\n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nfunction fixedTopicFromSearch(search: string): string {\n if (!search) return ''\n const params = new URLSearchParams(search)\n if (params.get('fixed_chat') !== '1' && params.get('embed_chat') !== '1') return ''\n return String(params.get('fixed_topic_id') || params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n}\n\nfunction agentFromSearch(search: string): string {\n if (!search) return ''\n const params = new URLSearchParams(search)\n return String(params.get('agent_id') || params.get('agentId') || '').trim()\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [fixedChatMode, setFixedChatMode] = useState(() => {\n if (typeof window === 'undefined') return false\n const params = new URLSearchParams(window.location.search)\n return params.get('fixed_chat') === '1' || params.get('embed_chat') === '1'\n })\n const [fixedTopicId, setFixedTopicId] = useState(() => {\n if (typeof window === 'undefined') return ''\n return fixedTopicFromSearch(window.location.search)\n })\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n setFixedChatMode(params.get('fixed_chat') === '1' || params.get('embed_chat') === '1')\n setFixedTopicId(fixedTopicFromSearch(window.location.search))\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n if (fixedChatMode) return\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [fixedChatMode])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (fixedChatMode && selectedAgentId && !agents.some((a) => a.agent_id === selectedAgentId)) return\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, fixedChatMode, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined') return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const agentFromUrl = agentFromSearch(key)\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (fixedChatMode || agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, fixedChatMode, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (fixedChatMode) {\n if (fixedTopicId && selectedTopicId !== fixedTopicId) {\n pendingCreatedTopicIdRef.current = fixedTopicId\n setSelectedTopicId(fixedTopicId)\n }\n return\n }\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [fixedChatMode, fixedTopicId, selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (fixedChatMode && topicFromUrl) {\n deepLinkTopicAppliedRef.current = key\n pendingCreatedTopicIdRef.current = topicFromUrl\n setFixedTopicId(topicFromUrl)\n setSelectedTopicId(topicFromUrl)\n return\n }\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [fixedChatMode, topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n const selectedTypingState = selectedTopicId ? typingByTopic[selectedTopicId] : undefined\n const pollSelectedMessages = shouldPollMobileMessages(selectedTypingState)\n\n const canFetchMessages = Boolean(token && selectedTopicId && (selectedAgentId || fixedChatMode))\n const messageHistoryOwner = `${selectedAgentId || 'auto'}:${selectedTaskId || ''}:${fixedChatMode ? 'fixed' : 'feed'}`\n const messageHistoryRef = useRef>(new Map())\n const messageHistoryKey = `${selectedTopicId || ''}:${messageHistoryOwner}`\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n canFetchMessages ? ['mobile-messages', token, selectedAgentId || 'auto', selectedTopicId, selectedTaskId, fixedChatMode] : null,\n async () => {\n // Load a useful history window once, then keep polling responses small and\n // merge them into the per-topic cache.\n const cachedHistory = messageHistoryRef.current.get(messageHistoryKey)\n || readCachedMessageHistory('mobile', selectedTopicId, messageHistoryOwner)\n const historyLimit = cachedHistory?.length ? '100' : '500'\n const params = new URLSearchParams({ limit: historyLimit })\n if (selectedAgentId) params.set('agent_id', selectedAgentId)\n params.set('include_history', 'true')\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) {\n throw new Error(`Message history request failed (${res.status})`)\n }\n const incoming = await res.json()\n const merged = mergeMessageHistory(cachedHistory, Array.isArray(incoming) ? incoming : [])\n messageHistoryRef.current.set(messageHistoryKey, merged)\n writeCachedMessageHistory('mobile', selectedTopicId, messageHistoryOwner, merged)\n return merged\n },\n {\n refreshInterval: pollSelectedMessages ? 2000 : 0,\n revalidateOnFocus: true,\n keepPreviousData: false,\n fallbackData: readCachedMessageHistory('mobile', selectedTopicId, messageHistoryOwner),\n },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const merged = mergeMessageHistory(messageHistoryRef.current.get(messageHistoryKey), messagesRaw)\n messageHistoryRef.current.set(messageHistoryKey, merged)\n writeCachedMessageHistory('mobile', selectedTopicId, messageHistoryOwner, merged)\n }, [messageHistoryKey, messageHistoryOwner, messagesRaw, selectedTopicId])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n let latestAgentMessage: { id: string; senderId: string; senderName?: string; ts: number } | null = null\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n const senderType = String(rec.sender_type || '').toLowerCase()\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (senderType === 'agent' && !progress) {\n const id = String(rec.message_id || rec.id || '')\n if (id && (!latestAgentMessage || ts >= latestAgentMessage.ts)) {\n latestAgentMessage = {\n id,\n senderId: String(rec.sender_id || selectedAgentId),\n senderName: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n ts,\n }\n }\n }\n if (!progress) continue\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (\n latestAgentMessage\n &&\n shouldCloseWaitingStatusFromAgentReply(nextState, latestAgentMessage, now, STATUS_STALE_MS)\n ) {\n nextState = appendTypingStatus(nextState, {\n agentId: latestAgentMessage.senderId,\n agentName: latestAgentMessage.senderName || displayName(agents.find((agent) => agent.agent_id === latestAgentMessage.senderId)),\n statusText: 'Agent 已回复',\n statusKind: 'response',\n adapter: nextState.adapter,\n ttlMs: COMPLETE_HOLD_MS,\n }, Math.max(now, latestAgentMessage.ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const rawType = String(rawEvent.type || '').toLowerCase()\n if (rawType === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n if (['task_status', 'task_update', 'run_status', 'agent_status'].includes(rawType)) {\n const rawTaskId = String(rawEvent.task_id || rawEvent.taskId || '').trim()\n const tid = String(rawEvent.topic_id || rawEvent.topicId || (rawTaskId && rawTaskId === selectedTopic?.task_id ? selectedTopicId : '')).trim()\n if (!tid) return\n const aid = String(rawEvent.agent_id || rawEvent.runner_agent_id || topicActorAgentId || selectedAgentId)\n const title = eventString(rawEvent, ['task_title', 'title', 'name']) || compactTopicTitle(selectedTopic)\n const status = eventString(rawEvent, ['status', 'task_status', 'state']) || rawType\n const statusText = statusTextFromTypingEvent(rawEvent) || taskStatusText(status, title) || 'Agent 状态更新'\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(labelForAgentInTopic(aid), roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText,\n statusKind: statusKindFromTypingEvent(rawEvent) || status,\n ttlMs: isTerminalStatusKind(status) ? COMPLETE_HOLD_MS : 120000,\n }, Date.now()),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return list\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgentId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (typing) {\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }\n if (!selectedTopic?.task_id) return null\n const status = String(selectedTopic.task_status || '').trim()\n const text = taskStatusText(status, compactTopicTitle(selectedTopic))\n if (!status || !text) return null\n const agentId = String(selectedTopic.runner_agent_id || topicActorAgentId || selectedAgentId || '').trim()\n const agent = agents.find((item) => item.agent_id === agentId) || topicActorAgent || selectedAgent\n const now = Date.now()\n return {\n agentId,\n agentName: displayName(agent) || 'Agent',\n statusText: text,\n statusKind: status,\n lines: [{ id: `${selectedTopicId}-${status}`, text, kind: status, ts: now }],\n startedAt: now,\n expiresAt: Number.MAX_SAFE_INTEGER,\n wsState,\n }\n }, [agents, selectedAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgent, topicActorAgentId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已投递,等待 Agent 执行',\n statusKind: 'accepted',\n ttlMs: 120000,\n }, Date.now()),\n }))\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n {!fixedChatMode && (\n
\n \n
\n
\n \n
{compactTopicTitle(selectedTopic)}
\n
\n
\n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '选择 Agent'}\n {selectedTopicMeta && ·}\n {selectedTopicMeta && {selectedTopicMeta}}\n \n
\n
\n <>\n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n \n
\n )}\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {!fixedChatMode && isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n \n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {!fixedChatMode && selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {!fixedChatMode && settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "edd6a3a2aea0655436e661b262b56ceecbc5e01c": {"t": 1785773550.433235, "n": 118540, "h": "30cbe80335d5d714", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport {\n isTerminalMobileStatusKind,\n normalizeMobileProgressStatusKind,\n shouldCloseWaitingStatusFromAgentReply,\n shouldPollMobileMessages,\n} from '@/lib/mobile-chat-status'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n task_status?: string\n runner_agent_id?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype MobileRunStatus = {\n agentId: string\n agentName: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n lines: TypingState['statusLines']\n startedAt: number\n expiresAt: number\n wsState: string\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = normalizeMobileProgressStatusKind(group, detail, status)\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('todo')) return '待执行'\n if (normalized.includes('doing') || normalized.includes('running')) return '执行中'\n if (normalized.includes('review')) return '待验收'\n if (normalized.includes('blocked')) return '阻塞'\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction taskStatusText(status?: string, title?: string): string {\n const normalized = String(status || '').toLowerCase()\n const subject = title ? `「${title}」` : '当前任务'\n if (normalized === 'todo') return `${subject}已创建,等待 Agent 接收`\n if (normalized === 'doing') return `${subject}执行中`\n if (normalized === 'review') return `${subject}已完成执行,等待验收`\n if (normalized === 'done') return `${subject}已完成`\n if (normalized === 'blocked') return `${subject}已阻塞`\n if (normalized === 'cancelled') return `${subject}已取消`\n return normalized ? `${subject}状态:${normalized}` : ''\n}\n\nfunction isTerminalStatusKind(kind?: string): boolean {\n return isTerminalMobileStatusKind(kind)\n}\n\nfunction adapterStatusLabel(adapter?: string): string {\n const value = String(adapter || '').trim()\n if (value === 'claude-code') return 'Claude Code'\n if (value === 'codex') return 'Codex'\n if (value === 'gemini') return 'Gemini'\n return value\n}\n\nfunction MobileAgentRunStatusCard({ status, compact = false }: { status: MobileRunStatus; compact?: boolean }) {\n const terminal = isTerminalStatusKind(status.statusKind)\n const lines = status.lines.slice(compact ? -4 : -6)\n const subtitle = [adapterStatusLabel(status.adapter), status.model].filter(Boolean).join(' · ')\n\n return (\n
\n
\n \n
\n
\n {status.agentName}\n \n {mobileStatusKindLabel(status.statusKind)}\n \n {subtitle && {subtitle}}\n WS {status.wsState}\n
\n

{status.statusText}

\n
\n
\n {lines.length > 0 && (\n
\n {lines.map((line) => (\n
\n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nfunction fixedTopicFromSearch(search: string): string {\n if (!search) return ''\n const params = new URLSearchParams(search)\n if (params.get('fixed_chat') !== '1' && params.get('embed_chat') !== '1') return ''\n return String(params.get('fixed_topic_id') || params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n}\n\nfunction agentFromSearch(search: string): string {\n if (!search) return ''\n const params = new URLSearchParams(search)\n return String(params.get('agent_id') || params.get('agentId') || '').trim()\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [fixedChatMode, setFixedChatMode] = useState(() => {\n if (typeof window === 'undefined') return false\n const params = new URLSearchParams(window.location.search)\n return params.get('fixed_chat') === '1' || params.get('embed_chat') === '1'\n })\n const [fixedTopicId, setFixedTopicId] = useState(() => {\n if (typeof window === 'undefined') return ''\n return fixedTopicFromSearch(window.location.search)\n })\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n setFixedChatMode(params.get('fixed_chat') === '1' || params.get('embed_chat') === '1')\n setFixedTopicId(fixedTopicFromSearch(window.location.search))\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n if (fixedChatMode) return\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [fixedChatMode])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (fixedChatMode && selectedAgentId && !agents.some((a) => a.agent_id === selectedAgentId)) return\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, fixedChatMode, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined') return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const agentFromUrl = agentFromSearch(key)\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (fixedChatMode || agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, fixedChatMode, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (fixedChatMode) {\n if (fixedTopicId && selectedTopicId !== fixedTopicId) {\n pendingCreatedTopicIdRef.current = fixedTopicId\n setSelectedTopicId(fixedTopicId)\n }\n return\n }\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [fixedChatMode, fixedTopicId, selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (fixedChatMode && topicFromUrl) {\n deepLinkTopicAppliedRef.current = key\n pendingCreatedTopicIdRef.current = topicFromUrl\n setFixedTopicId(topicFromUrl)\n setSelectedTopicId(topicFromUrl)\n return\n }\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [fixedChatMode, topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n const selectedTypingState = selectedTopicId ? typingByTopic[selectedTopicId] : undefined\n const pollSelectedMessages = shouldPollMobileMessages(selectedTypingState)\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId, selectedTaskId, fixedChatMode] : null,\n async () => {\n const historyLimit = fixedChatMode || selectedTaskId ? '500' : '80'\n const params = new URLSearchParams({ limit: historyLimit, agent_id: selectedAgentId })\n if (fixedChatMode || selectedTaskId) params.set('include_history', 'true')\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: pollSelectedMessages ? 2000 : 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n let latestAgentMessage: { id: string; senderId: string; senderName?: string; ts: number } | null = null\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n const senderType = String(rec.sender_type || '').toLowerCase()\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (senderType === 'agent' && !progress) {\n const id = String(rec.message_id || rec.id || '')\n if (id && (!latestAgentMessage || ts >= latestAgentMessage.ts)) {\n latestAgentMessage = {\n id,\n senderId: String(rec.sender_id || selectedAgentId),\n senderName: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n ts,\n }\n }\n }\n if (!progress) continue\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (\n latestAgentMessage\n &&\n shouldCloseWaitingStatusFromAgentReply(nextState, latestAgentMessage, now, STATUS_STALE_MS)\n ) {\n nextState = appendTypingStatus(nextState, {\n agentId: latestAgentMessage.senderId,\n agentName: latestAgentMessage.senderName || displayName(agents.find((agent) => agent.agent_id === latestAgentMessage.senderId)),\n statusText: 'Agent 已回复',\n statusKind: 'response',\n adapter: nextState.adapter,\n ttlMs: COMPLETE_HOLD_MS,\n }, Math.max(now, latestAgentMessage.ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const rawType = String(rawEvent.type || '').toLowerCase()\n if (rawType === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n if (['task_status', 'task_update', 'run_status', 'agent_status'].includes(rawType)) {\n const rawTaskId = String(rawEvent.task_id || rawEvent.taskId || '').trim()\n const tid = String(rawEvent.topic_id || rawEvent.topicId || (rawTaskId && rawTaskId === selectedTopic?.task_id ? selectedTopicId : '')).trim()\n if (!tid) return\n const aid = String(rawEvent.agent_id || rawEvent.runner_agent_id || topicActorAgentId || selectedAgentId)\n const title = eventString(rawEvent, ['task_title', 'title', 'name']) || compactTopicTitle(selectedTopic)\n const status = eventString(rawEvent, ['status', 'task_status', 'state']) || rawType\n const statusText = statusTextFromTypingEvent(rawEvent) || taskStatusText(status, title) || 'Agent 状态更新'\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(labelForAgentInTopic(aid), roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText,\n statusKind: statusKindFromTypingEvent(rawEvent) || status,\n ttlMs: isTerminalStatusKind(status) ? COMPLETE_HOLD_MS : 120000,\n }, Date.now()),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgentId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (typing) {\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }\n if (!selectedTopic?.task_id) return null\n const status = String(selectedTopic.task_status || '').trim()\n const text = taskStatusText(status, compactTopicTitle(selectedTopic))\n if (!status || !text) return null\n const agentId = String(selectedTopic.runner_agent_id || topicActorAgentId || selectedAgentId || '').trim()\n const agent = agents.find((item) => item.agent_id === agentId) || topicActorAgent || selectedAgent\n const now = Date.now()\n return {\n agentId,\n agentName: displayName(agent) || 'Agent',\n statusText: text,\n statusKind: status,\n lines: [{ id: `${selectedTopicId}-${status}`, text, kind: status, ts: now }],\n startedAt: now,\n expiresAt: Number.MAX_SAFE_INTEGER,\n wsState,\n }\n }, [agents, selectedAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgent, topicActorAgentId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已投递,等待 Agent 执行',\n statusKind: 'accepted',\n ttlMs: 120000,\n }, Date.now()),\n }))\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n {!fixedChatMode && (\n
\n \n
\n
\n \n
{compactTopicTitle(selectedTopic)}
\n
\n
\n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '选择 Agent'}\n {selectedTopicMeta && ·}\n {selectedTopicMeta && {selectedTopicMeta}}\n \n
\n
\n <>\n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n \n
\n )}\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {!fixedChatMode && isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n \n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {!fixedChatMode && selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {!fixedChatMode && settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "e8274e1c9203f98765d4d718c8615e36e15906bc": {"t": 1785773554.2262897, "n": 118346, "h": "4e7429f2ffecbd9c", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport {\n isTerminalMobileStatusKind,\n normalizeMobileProgressStatusKind,\n shouldCloseWaitingStatusFromAgentReply,\n shouldPollMobileMessages,\n} from '@/lib/mobile-chat-status'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n task_status?: string\n runner_agent_id?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype MobileRunStatus = {\n agentId: string\n agentName: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n lines: TypingState['statusLines']\n startedAt: number\n expiresAt: number\n wsState: string\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = normalizeMobileProgressStatusKind(group, detail, status)\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('todo')) return '待执行'\n if (normalized.includes('doing') || normalized.includes('running')) return '执行中'\n if (normalized.includes('review')) return '待验收'\n if (normalized.includes('blocked')) return '阻塞'\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction taskStatusText(status?: string, title?: string): string {\n const normalized = String(status || '').toLowerCase()\n const subject = title ? `「${title}」` : '当前任务'\n if (normalized === 'todo') return `${subject}已创建,等待 Agent 接收`\n if (normalized === 'doing') return `${subject}执行中`\n if (normalized === 'review') return `${subject}已完成执行,等待验收`\n if (normalized === 'done') return `${subject}已完成`\n if (normalized === 'blocked') return `${subject}已阻塞`\n if (normalized === 'cancelled') return `${subject}已取消`\n return normalized ? `${subject}状态:${normalized}` : ''\n}\n\nfunction isTerminalStatusKind(kind?: string): boolean {\n return isTerminalMobileStatusKind(kind)\n}\n\nfunction adapterStatusLabel(adapter?: string): string {\n const value = String(adapter || '').trim()\n if (value === 'claude-code') return 'Claude Code'\n if (value === 'codex') return 'Codex'\n if (value === 'gemini') return 'Gemini'\n return value\n}\n\nfunction MobileAgentRunStatusCard({ status, compact = false }: { status: MobileRunStatus; compact?: boolean }) {\n const terminal = isTerminalStatusKind(status.statusKind)\n const lines = status.lines.slice(compact ? -4 : -6)\n const subtitle = [adapterStatusLabel(status.adapter), status.model].filter(Boolean).join(' · ')\n\n return (\n
\n
\n \n
\n
\n {status.agentName}\n \n {mobileStatusKindLabel(status.statusKind)}\n \n {subtitle && {subtitle}}\n WS {status.wsState}\n
\n

{status.statusText}

\n
\n
\n {lines.length > 0 && (\n
\n {lines.map((line) => (\n
\n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nfunction fixedTopicFromSearch(search: string): string {\n if (!search) return ''\n const params = new URLSearchParams(search)\n if (params.get('fixed_chat') !== '1' && params.get('embed_chat') !== '1') return ''\n return String(params.get('fixed_topic_id') || params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n}\n\nfunction agentFromSearch(search: string): string {\n if (!search) return ''\n const params = new URLSearchParams(search)\n return String(params.get('agent_id') || params.get('agentId') || '').trim()\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [fixedChatMode, setFixedChatMode] = useState(() => {\n if (typeof window === 'undefined') return false\n const params = new URLSearchParams(window.location.search)\n return params.get('fixed_chat') === '1' || params.get('embed_chat') === '1'\n })\n const [fixedTopicId, setFixedTopicId] = useState(() => {\n if (typeof window === 'undefined') return ''\n return fixedTopicFromSearch(window.location.search)\n })\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n setFixedChatMode(params.get('fixed_chat') === '1' || params.get('embed_chat') === '1')\n setFixedTopicId(fixedTopicFromSearch(window.location.search))\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n if (fixedChatMode) return\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [fixedChatMode])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (fixedChatMode && selectedAgentId && !agents.some((a) => a.agent_id === selectedAgentId)) return\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, fixedChatMode, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined') return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const agentFromUrl = agentFromSearch(key)\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (fixedChatMode || agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, fixedChatMode, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (fixedChatMode) {\n if (fixedTopicId && selectedTopicId !== fixedTopicId) {\n pendingCreatedTopicIdRef.current = fixedTopicId\n setSelectedTopicId(fixedTopicId)\n }\n return\n }\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [fixedChatMode, fixedTopicId, selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (fixedChatMode && topicFromUrl) {\n deepLinkTopicAppliedRef.current = key\n pendingCreatedTopicIdRef.current = topicFromUrl\n setFixedTopicId(topicFromUrl)\n setSelectedTopicId(topicFromUrl)\n return\n }\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [fixedChatMode, topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n const selectedTypingState = selectedTopicId ? typingByTopic[selectedTopicId] : undefined\n const pollSelectedMessages = shouldPollMobileMessages(selectedTypingState)\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: pollSelectedMessages ? 2000 : 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n let latestAgentMessage: { id: string; senderId: string; senderName?: string; ts: number } | null = null\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n const senderType = String(rec.sender_type || '').toLowerCase()\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (senderType === 'agent' && !progress) {\n const id = String(rec.message_id || rec.id || '')\n if (id && (!latestAgentMessage || ts >= latestAgentMessage.ts)) {\n latestAgentMessage = {\n id,\n senderId: String(rec.sender_id || selectedAgentId),\n senderName: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n ts,\n }\n }\n }\n if (!progress) continue\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (\n latestAgentMessage\n &&\n shouldCloseWaitingStatusFromAgentReply(nextState, latestAgentMessage, now, STATUS_STALE_MS)\n ) {\n nextState = appendTypingStatus(nextState, {\n agentId: latestAgentMessage.senderId,\n agentName: latestAgentMessage.senderName || displayName(agents.find((agent) => agent.agent_id === latestAgentMessage.senderId)),\n statusText: 'Agent 已回复',\n statusKind: 'response',\n adapter: nextState.adapter,\n ttlMs: COMPLETE_HOLD_MS,\n }, Math.max(now, latestAgentMessage.ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const rawType = String(rawEvent.type || '').toLowerCase()\n if (rawType === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n if (['task_status', 'task_update', 'run_status', 'agent_status'].includes(rawType)) {\n const rawTaskId = String(rawEvent.task_id || rawEvent.taskId || '').trim()\n const tid = String(rawEvent.topic_id || rawEvent.topicId || (rawTaskId && rawTaskId === selectedTopic?.task_id ? selectedTopicId : '')).trim()\n if (!tid) return\n const aid = String(rawEvent.agent_id || rawEvent.runner_agent_id || topicActorAgentId || selectedAgentId)\n const title = eventString(rawEvent, ['task_title', 'title', 'name']) || compactTopicTitle(selectedTopic)\n const status = eventString(rawEvent, ['status', 'task_status', 'state']) || rawType\n const statusText = statusTextFromTypingEvent(rawEvent) || taskStatusText(status, title) || 'Agent 状态更新'\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(labelForAgentInTopic(aid), roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText,\n statusKind: statusKindFromTypingEvent(rawEvent) || status,\n ttlMs: isTerminalStatusKind(status) ? COMPLETE_HOLD_MS : 120000,\n }, Date.now()),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgentId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (typing) {\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }\n if (!selectedTopic?.task_id) return null\n const status = String(selectedTopic.task_status || '').trim()\n const text = taskStatusText(status, compactTopicTitle(selectedTopic))\n if (!status || !text) return null\n const agentId = String(selectedTopic.runner_agent_id || topicActorAgentId || selectedAgentId || '').trim()\n const agent = agents.find((item) => item.agent_id === agentId) || topicActorAgent || selectedAgent\n const now = Date.now()\n return {\n agentId,\n agentName: displayName(agent) || 'Agent',\n statusText: text,\n statusKind: status,\n lines: [{ id: `${selectedTopicId}-${status}`, text, kind: status, ts: now }],\n startedAt: now,\n expiresAt: Number.MAX_SAFE_INTEGER,\n wsState,\n }\n }, [agents, selectedAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgent, topicActorAgentId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已投递,等待 Agent 执行',\n statusKind: 'accepted',\n ttlMs: 120000,\n }, Date.now()),\n }))\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n {!fixedChatMode && (\n
\n \n
\n
\n \n
{compactTopicTitle(selectedTopic)}
\n
\n
\n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '选择 Agent'}\n {selectedTopicMeta && ·}\n {selectedTopicMeta && {selectedTopicMeta}}\n \n
\n
\n <>\n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n \n
\n )}\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {!fixedChatMode && isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n \n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {!fixedChatMode && selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {!fixedChatMode && settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "8e2a0ccdcdb7183701d0f9a008e960edccd650ad": {"t": 1785773560.134893, "n": 118103, "h": "0aa2051309738b76", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport {\n isTerminalMobileStatusKind,\n normalizeMobileProgressStatusKind,\n shouldCloseWaitingStatusFromAgentReply,\n shouldPollMobileMessages,\n} from '@/lib/mobile-chat-status'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n task_status?: string\n runner_agent_id?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype MobileRunStatus = {\n agentId: string\n agentName: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n lines: TypingState['statusLines']\n startedAt: number\n expiresAt: number\n wsState: string\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = normalizeMobileProgressStatusKind(group, detail, status)\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('todo')) return '待执行'\n if (normalized.includes('doing') || normalized.includes('running')) return '执行中'\n if (normalized.includes('review')) return '待验收'\n if (normalized.includes('blocked')) return '阻塞'\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction taskStatusText(status?: string, title?: string): string {\n const normalized = String(status || '').toLowerCase()\n const subject = title ? `「${title}」` : '当前任务'\n if (normalized === 'todo') return `${subject}已创建,等待 Agent 接收`\n if (normalized === 'doing') return `${subject}执行中`\n if (normalized === 'review') return `${subject}已完成执行,等待验收`\n if (normalized === 'done') return `${subject}已完成`\n if (normalized === 'blocked') return `${subject}已阻塞`\n if (normalized === 'cancelled') return `${subject}已取消`\n return normalized ? `${subject}状态:${normalized}` : ''\n}\n\nfunction isTerminalStatusKind(kind?: string): boolean {\n return isTerminalMobileStatusKind(kind)\n}\n\nfunction adapterStatusLabel(adapter?: string): string {\n const value = String(adapter || '').trim()\n if (value === 'claude-code') return 'Claude Code'\n if (value === 'codex') return 'Codex'\n if (value === 'gemini') return 'Gemini'\n return value\n}\n\nfunction MobileAgentRunStatusCard({ status, compact = false }: { status: MobileRunStatus; compact?: boolean }) {\n const terminal = isTerminalStatusKind(status.statusKind)\n const lines = status.lines.slice(compact ? -4 : -6)\n const subtitle = [adapterStatusLabel(status.adapter), status.model].filter(Boolean).join(' · ')\n\n return (\n
\n
\n \n
\n
\n {status.agentName}\n \n {mobileStatusKindLabel(status.statusKind)}\n \n {subtitle && {subtitle}}\n WS {status.wsState}\n
\n

{status.statusText}

\n
\n
\n {lines.length > 0 && (\n
\n {lines.map((line) => (\n
\n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nfunction fixedTopicFromSearch(search: string): string {\n if (!search) return ''\n const params = new URLSearchParams(search)\n if (params.get('fixed_chat') !== '1' && params.get('embed_chat') !== '1') return ''\n return String(params.get('fixed_topic_id') || params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [fixedChatMode, setFixedChatMode] = useState(() => {\n if (typeof window === 'undefined') return false\n const params = new URLSearchParams(window.location.search)\n return params.get('fixed_chat') === '1' || params.get('embed_chat') === '1'\n })\n const [fixedTopicId, setFixedTopicId] = useState(() => {\n if (typeof window === 'undefined') return ''\n return fixedTopicFromSearch(window.location.search)\n })\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n setFixedChatMode(params.get('fixed_chat') === '1' || params.get('embed_chat') === '1')\n setFixedTopicId(fixedTopicFromSearch(window.location.search))\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n if (fixedChatMode) return\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [fixedChatMode])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (fixedChatMode) {\n if (fixedTopicId && selectedTopicId !== fixedTopicId) {\n pendingCreatedTopicIdRef.current = fixedTopicId\n setSelectedTopicId(fixedTopicId)\n }\n return\n }\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [fixedChatMode, fixedTopicId, selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (fixedChatMode && topicFromUrl) {\n deepLinkTopicAppliedRef.current = key\n pendingCreatedTopicIdRef.current = topicFromUrl\n setFixedTopicId(topicFromUrl)\n setSelectedTopicId(topicFromUrl)\n return\n }\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [fixedChatMode, topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n const selectedTypingState = selectedTopicId ? typingByTopic[selectedTopicId] : undefined\n const pollSelectedMessages = shouldPollMobileMessages(selectedTypingState)\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: pollSelectedMessages ? 2000 : 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n let latestAgentMessage: { id: string; senderId: string; senderName?: string; ts: number } | null = null\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n const senderType = String(rec.sender_type || '').toLowerCase()\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (senderType === 'agent' && !progress) {\n const id = String(rec.message_id || rec.id || '')\n if (id && (!latestAgentMessage || ts >= latestAgentMessage.ts)) {\n latestAgentMessage = {\n id,\n senderId: String(rec.sender_id || selectedAgentId),\n senderName: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n ts,\n }\n }\n }\n if (!progress) continue\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (\n latestAgentMessage\n &&\n shouldCloseWaitingStatusFromAgentReply(nextState, latestAgentMessage, now, STATUS_STALE_MS)\n ) {\n nextState = appendTypingStatus(nextState, {\n agentId: latestAgentMessage.senderId,\n agentName: latestAgentMessage.senderName || displayName(agents.find((agent) => agent.agent_id === latestAgentMessage.senderId)),\n statusText: 'Agent 已回复',\n statusKind: 'response',\n adapter: nextState.adapter,\n ttlMs: COMPLETE_HOLD_MS,\n }, Math.max(now, latestAgentMessage.ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const rawType = String(rawEvent.type || '').toLowerCase()\n if (rawType === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n if (['task_status', 'task_update', 'run_status', 'agent_status'].includes(rawType)) {\n const rawTaskId = String(rawEvent.task_id || rawEvent.taskId || '').trim()\n const tid = String(rawEvent.topic_id || rawEvent.topicId || (rawTaskId && rawTaskId === selectedTopic?.task_id ? selectedTopicId : '')).trim()\n if (!tid) return\n const aid = String(rawEvent.agent_id || rawEvent.runner_agent_id || topicActorAgentId || selectedAgentId)\n const title = eventString(rawEvent, ['task_title', 'title', 'name']) || compactTopicTitle(selectedTopic)\n const status = eventString(rawEvent, ['status', 'task_status', 'state']) || rawType\n const statusText = statusTextFromTypingEvent(rawEvent) || taskStatusText(status, title) || 'Agent 状态更新'\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(labelForAgentInTopic(aid), roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText,\n statusKind: statusKindFromTypingEvent(rawEvent) || status,\n ttlMs: isTerminalStatusKind(status) ? COMPLETE_HOLD_MS : 120000,\n }, Date.now()),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgentId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (typing) {\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }\n if (!selectedTopic?.task_id) return null\n const status = String(selectedTopic.task_status || '').trim()\n const text = taskStatusText(status, compactTopicTitle(selectedTopic))\n if (!status || !text) return null\n const agentId = String(selectedTopic.runner_agent_id || topicActorAgentId || selectedAgentId || '').trim()\n const agent = agents.find((item) => item.agent_id === agentId) || topicActorAgent || selectedAgent\n const now = Date.now()\n return {\n agentId,\n agentName: displayName(agent) || 'Agent',\n statusText: text,\n statusKind: status,\n lines: [{ id: `${selectedTopicId}-${status}`, text, kind: status, ts: now }],\n startedAt: now,\n expiresAt: Number.MAX_SAFE_INTEGER,\n wsState,\n }\n }, [agents, selectedAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgent, topicActorAgentId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已投递,等待 Agent 执行',\n statusKind: 'accepted',\n ttlMs: 120000,\n }, Date.now()),\n }))\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n {!fixedChatMode && (\n
\n \n
\n
\n \n
{compactTopicTitle(selectedTopic)}
\n
\n
\n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '选择 Agent'}\n {selectedTopicMeta && ·}\n {selectedTopicMeta && {selectedTopicMeta}}\n \n
\n
\n <>\n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n \n
\n )}\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {!fixedChatMode && isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n \n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {!fixedChatMode && selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {!fixedChatMode && settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "e4e1ed2e8d804057e5df843ab774191eda3aa09b": {"t": 1785773562.0211675, "n": 117032, "h": "6544d8b97484915d", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport {\n isTerminalMobileStatusKind,\n normalizeMobileProgressStatusKind,\n shouldCloseWaitingStatusFromAgentReply,\n shouldPollMobileMessages,\n} from '@/lib/mobile-chat-status'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n task_status?: string\n runner_agent_id?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype MobileRunStatus = {\n agentId: string\n agentName: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n lines: TypingState['statusLines']\n startedAt: number\n expiresAt: number\n wsState: string\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = normalizeMobileProgressStatusKind(group, detail, status)\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('todo')) return '待执行'\n if (normalized.includes('doing') || normalized.includes('running')) return '执行中'\n if (normalized.includes('review')) return '待验收'\n if (normalized.includes('blocked')) return '阻塞'\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction taskStatusText(status?: string, title?: string): string {\n const normalized = String(status || '').toLowerCase()\n const subject = title ? `「${title}」` : '当前任务'\n if (normalized === 'todo') return `${subject}已创建,等待 Agent 接收`\n if (normalized === 'doing') return `${subject}执行中`\n if (normalized === 'review') return `${subject}已完成执行,等待验收`\n if (normalized === 'done') return `${subject}已完成`\n if (normalized === 'blocked') return `${subject}已阻塞`\n if (normalized === 'cancelled') return `${subject}已取消`\n return normalized ? `${subject}状态:${normalized}` : ''\n}\n\nfunction isTerminalStatusKind(kind?: string): boolean {\n return isTerminalMobileStatusKind(kind)\n}\n\nfunction adapterStatusLabel(adapter?: string): string {\n const value = String(adapter || '').trim()\n if (value === 'claude-code') return 'Claude Code'\n if (value === 'codex') return 'Codex'\n if (value === 'gemini') return 'Gemini'\n return value\n}\n\nfunction MobileAgentRunStatusCard({ status, compact = false }: { status: MobileRunStatus; compact?: boolean }) {\n const terminal = isTerminalStatusKind(status.statusKind)\n const lines = status.lines.slice(compact ? -4 : -6)\n const subtitle = [adapterStatusLabel(status.adapter), status.model].filter(Boolean).join(' · ')\n\n return (\n
\n
\n \n
\n
\n {status.agentName}\n \n {mobileStatusKindLabel(status.statusKind)}\n \n {subtitle && {subtitle}}\n WS {status.wsState}\n
\n

{status.statusText}

\n
\n
\n {lines.length > 0 && (\n
\n {lines.map((line) => (\n
\n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [fixedChatMode, setFixedChatMode] = useState(() => {\n if (typeof window === 'undefined') return false\n const params = new URLSearchParams(window.location.search)\n return params.get('fixed_chat') === '1' || params.get('embed_chat') === '1'\n })\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n setFixedChatMode(params.get('fixed_chat') === '1' || params.get('embed_chat') === '1')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n if (fixedChatMode) return\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [fixedChatMode])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n const selectedTypingState = selectedTopicId ? typingByTopic[selectedTopicId] : undefined\n const pollSelectedMessages = shouldPollMobileMessages(selectedTypingState)\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: pollSelectedMessages ? 2000 : 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n let latestAgentMessage: { id: string; senderId: string; senderName?: string; ts: number } | null = null\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n const senderType = String(rec.sender_type || '').toLowerCase()\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (senderType === 'agent' && !progress) {\n const id = String(rec.message_id || rec.id || '')\n if (id && (!latestAgentMessage || ts >= latestAgentMessage.ts)) {\n latestAgentMessage = {\n id,\n senderId: String(rec.sender_id || selectedAgentId),\n senderName: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n ts,\n }\n }\n }\n if (!progress) continue\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (\n latestAgentMessage\n &&\n shouldCloseWaitingStatusFromAgentReply(nextState, latestAgentMessage, now, STATUS_STALE_MS)\n ) {\n nextState = appendTypingStatus(nextState, {\n agentId: latestAgentMessage.senderId,\n agentName: latestAgentMessage.senderName || displayName(agents.find((agent) => agent.agent_id === latestAgentMessage.senderId)),\n statusText: 'Agent 已回复',\n statusKind: 'response',\n adapter: nextState.adapter,\n ttlMs: COMPLETE_HOLD_MS,\n }, Math.max(now, latestAgentMessage.ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const rawType = String(rawEvent.type || '').toLowerCase()\n if (rawType === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n if (['task_status', 'task_update', 'run_status', 'agent_status'].includes(rawType)) {\n const rawTaskId = String(rawEvent.task_id || rawEvent.taskId || '').trim()\n const tid = String(rawEvent.topic_id || rawEvent.topicId || (rawTaskId && rawTaskId === selectedTopic?.task_id ? selectedTopicId : '')).trim()\n if (!tid) return\n const aid = String(rawEvent.agent_id || rawEvent.runner_agent_id || topicActorAgentId || selectedAgentId)\n const title = eventString(rawEvent, ['task_title', 'title', 'name']) || compactTopicTitle(selectedTopic)\n const status = eventString(rawEvent, ['status', 'task_status', 'state']) || rawType\n const statusText = statusTextFromTypingEvent(rawEvent) || taskStatusText(status, title) || 'Agent 状态更新'\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(labelForAgentInTopic(aid), roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText,\n statusKind: statusKindFromTypingEvent(rawEvent) || status,\n ttlMs: isTerminalStatusKind(status) ? COMPLETE_HOLD_MS : 120000,\n }, Date.now()),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgentId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (typing) {\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }\n if (!selectedTopic?.task_id) return null\n const status = String(selectedTopic.task_status || '').trim()\n const text = taskStatusText(status, compactTopicTitle(selectedTopic))\n if (!status || !text) return null\n const agentId = String(selectedTopic.runner_agent_id || topicActorAgentId || selectedAgentId || '').trim()\n const agent = agents.find((item) => item.agent_id === agentId) || topicActorAgent || selectedAgent\n const now = Date.now()\n return {\n agentId,\n agentName: displayName(agent) || 'Agent',\n statusText: text,\n statusKind: status,\n lines: [{ id: `${selectedTopicId}-${status}`, text, kind: status, ts: now }],\n startedAt: now,\n expiresAt: Number.MAX_SAFE_INTEGER,\n wsState,\n }\n }, [agents, selectedAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgent, topicActorAgentId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已投递,等待 Agent 执行',\n statusKind: 'accepted',\n ttlMs: 120000,\n }, Date.now()),\n }))\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n {!fixedChatMode && (\n
\n \n
\n
\n \n
{compactTopicTitle(selectedTopic)}
\n
\n
\n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '选择 Agent'}\n {selectedTopicMeta && ·}\n {selectedTopicMeta && {selectedTopicMeta}}\n \n
\n
\n <>\n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n \n
\n )}\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {!fixedChatMode && isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n \n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {!fixedChatMode && selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {!fixedChatMode && settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "d42c6b15cd5b7dcb04ea3bb5d78265447a071975": {"t": 1785773566.6668553, "n": 117070, "h": "ea69974d51465b48", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport {\n isTerminalMobileStatusKind,\n normalizeMobileProgressStatusKind,\n shouldCloseWaitingStatusFromAgentReply,\n shouldPollMobileMessages,\n} from '@/lib/mobile-chat-status'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n task_status?: string\n runner_agent_id?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype MobileRunStatus = {\n agentId: string\n agentName: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n lines: TypingState['statusLines']\n startedAt: number\n expiresAt: number\n wsState: string\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = normalizeMobileProgressStatusKind(group, detail, status)\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('todo')) return '待执行'\n if (normalized.includes('doing') || normalized.includes('running')) return '执行中'\n if (normalized.includes('review')) return '待验收'\n if (normalized.includes('blocked')) return '阻塞'\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction taskStatusText(status?: string, title?: string): string {\n const normalized = String(status || '').toLowerCase()\n const subject = title ? `「${title}」` : '当前任务'\n if (normalized === 'todo') return `${subject}已创建,等待 Agent 接收`\n if (normalized === 'doing') return `${subject}执行中`\n if (normalized === 'review') return `${subject}已完成执行,等待验收`\n if (normalized === 'done') return `${subject}已完成`\n if (normalized === 'blocked') return `${subject}已阻塞`\n if (normalized === 'cancelled') return `${subject}已取消`\n return normalized ? `${subject}状态:${normalized}` : ''\n}\n\nfunction isTerminalStatusKind(kind?: string): boolean {\n return isTerminalMobileStatusKind(kind)\n}\n\nfunction adapterStatusLabel(adapter?: string): string {\n const value = String(adapter || '').trim()\n if (value === 'claude-code') return 'Claude Code'\n if (value === 'codex') return 'Codex'\n if (value === 'gemini') return 'Gemini'\n return value\n}\n\nfunction MobileAgentRunStatusCard({ status, compact = false }: { status: MobileRunStatus; compact?: boolean }) {\n const terminal = isTerminalStatusKind(status.statusKind)\n const lines = status.lines.slice(compact ? -4 : -6)\n const subtitle = [adapterStatusLabel(status.adapter), status.model].filter(Boolean).join(' · ')\n\n return (\n
\n
\n \n
\n
\n {status.agentName}\n \n {mobileStatusKindLabel(status.statusKind)}\n \n {subtitle && {subtitle}}\n WS {status.wsState}\n
\n

{status.statusText}

\n
\n
\n {lines.length > 0 && (\n
\n {lines.map((line) => (\n
\n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [fixedChatMode, setFixedChatMode] = useState(() => {\n if (typeof window === 'undefined') return false\n const params = new URLSearchParams(window.location.search)\n return params.get('fixed_chat') === '1' || params.get('embed_chat') === '1'\n })\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n setFixedChatMode(params.get('fixed_chat') === '1' || params.get('embed_chat') === '1')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n if (fixedChatMode) return\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [fixedChatMode])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n const selectedTypingState = selectedTopicId ? typingByTopic[selectedTopicId] : undefined\n const pollSelectedMessages = shouldPollMobileMessages(selectedTypingState)\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: pollSelectedMessages ? 2000 : 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n let latestAgentMessage: { id: string; senderId: string; senderName?: string; ts: number } | null = null\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n const senderType = String(rec.sender_type || '').toLowerCase()\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (senderType === 'agent' && !progress) {\n const id = String(rec.message_id || rec.id || '')\n if (id && (!latestAgentMessage || ts >= latestAgentMessage.ts)) {\n latestAgentMessage = {\n id,\n senderId: String(rec.sender_id || selectedAgentId),\n senderName: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n ts,\n }\n }\n }\n if (!progress) continue\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (\n latestAgentMessage\n &&\n shouldCloseWaitingStatusFromAgentReply(nextState, latestAgentMessage, now, STATUS_STALE_MS)\n ) {\n nextState = appendTypingStatus(nextState, {\n agentId: latestAgentMessage.senderId,\n agentName: latestAgentMessage.senderName || displayName(agents.find((agent) => agent.agent_id === latestAgentMessage.senderId)),\n statusText: 'Agent 已回复',\n statusKind: 'response',\n adapter: nextState.adapter,\n ttlMs: COMPLETE_HOLD_MS,\n }, Math.max(now, latestAgentMessage.ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const rawType = String(rawEvent.type || '').toLowerCase()\n if (rawType === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n if (['task_status', 'task_update', 'run_status', 'agent_status'].includes(rawType)) {\n const rawTaskId = String(rawEvent.task_id || rawEvent.taskId || '').trim()\n const tid = String(rawEvent.topic_id || rawEvent.topicId || (rawTaskId && rawTaskId === selectedTopic?.task_id ? selectedTopicId : '')).trim()\n if (!tid) return\n const aid = String(rawEvent.agent_id || rawEvent.runner_agent_id || topicActorAgentId || selectedAgentId)\n const title = eventString(rawEvent, ['task_title', 'title', 'name']) || compactTopicTitle(selectedTopic)\n const status = eventString(rawEvent, ['status', 'task_status', 'state']) || rawType\n const statusText = statusTextFromTypingEvent(rawEvent) || taskStatusText(status, title) || 'Agent 状态更新'\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(labelForAgentInTopic(aid), roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText,\n statusKind: statusKindFromTypingEvent(rawEvent) || status,\n ttlMs: isTerminalStatusKind(status) ? COMPLETE_HOLD_MS : 120000,\n }, Date.now()),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgentId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (typing) {\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }\n if (!selectedTopic?.task_id) return null\n const status = String(selectedTopic.task_status || '').trim()\n const text = taskStatusText(status, compactTopicTitle(selectedTopic))\n if (!status || !text) return null\n const agentId = String(selectedTopic.runner_agent_id || topicActorAgentId || selectedAgentId || '').trim()\n const agent = agents.find((item) => item.agent_id === agentId) || topicActorAgent || selectedAgent\n const now = Date.now()\n return {\n agentId,\n agentName: displayName(agent) || 'Agent',\n statusText: text,\n statusKind: status,\n lines: [{ id: `${selectedTopicId}-${status}`, text, kind: status, ts: now }],\n startedAt: now,\n expiresAt: Number.MAX_SAFE_INTEGER,\n wsState,\n }\n }, [agents, selectedAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgent, topicActorAgentId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已投递,等待 Agent 执行',\n statusKind: 'accepted',\n ttlMs: 120000,\n }, Date.now()),\n }))\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n {!fixedChatMode && (\n \n )}\n
\n
\n \n
{compactTopicTitle(selectedTopic)}
\n
\n
\n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '选择 Agent'}\n {selectedTopicMeta && ·}\n {selectedTopicMeta && {selectedTopicMeta}}\n {!fixedChatMode && }\n
\n
\n {!fixedChatMode && (\n <>\n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n \n )}\n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {!fixedChatMode && isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n \n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {!fixedChatMode && selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {!fixedChatMode && settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "7f1eb92515fc0cdb2352d9227535cd98cf82db50": {"t": 1785773569.3280017, "n": 117016, "h": "f85502db888978e6", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport {\n isTerminalMobileStatusKind,\n shouldCloseWaitingStatusFromAgentReply,\n shouldPollMobileMessages,\n} from '@/lib/mobile-chat-status'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n task_status?: string\n runner_agent_id?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype MobileRunStatus = {\n agentId: string\n agentName: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n lines: TypingState['statusLines']\n startedAt: number\n expiresAt: number\n wsState: string\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('todo')) return '待执行'\n if (normalized.includes('doing') || normalized.includes('running')) return '执行中'\n if (normalized.includes('review')) return '待验收'\n if (normalized.includes('blocked')) return '阻塞'\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction taskStatusText(status?: string, title?: string): string {\n const normalized = String(status || '').toLowerCase()\n const subject = title ? `「${title}」` : '当前任务'\n if (normalized === 'todo') return `${subject}已创建,等待 Agent 接收`\n if (normalized === 'doing') return `${subject}执行中`\n if (normalized === 'review') return `${subject}已完成执行,等待验收`\n if (normalized === 'done') return `${subject}已完成`\n if (normalized === 'blocked') return `${subject}已阻塞`\n if (normalized === 'cancelled') return `${subject}已取消`\n return normalized ? `${subject}状态:${normalized}` : ''\n}\n\nfunction isTerminalStatusKind(kind?: string): boolean {\n return isTerminalMobileStatusKind(kind)\n}\n\nfunction adapterStatusLabel(adapter?: string): string {\n const value = String(adapter || '').trim()\n if (value === 'claude-code') return 'Claude Code'\n if (value === 'codex') return 'Codex'\n if (value === 'gemini') return 'Gemini'\n return value\n}\n\nfunction MobileAgentRunStatusCard({ status, compact = false }: { status: MobileRunStatus; compact?: boolean }) {\n const terminal = isTerminalStatusKind(status.statusKind)\n const lines = status.lines.slice(compact ? -4 : -6)\n const subtitle = [adapterStatusLabel(status.adapter), status.model].filter(Boolean).join(' · ')\n\n return (\n
\n
\n \n
\n
\n {status.agentName}\n \n {mobileStatusKindLabel(status.statusKind)}\n \n {subtitle && {subtitle}}\n WS {status.wsState}\n
\n

{status.statusText}

\n
\n
\n {lines.length > 0 && (\n
\n {lines.map((line) => (\n
\n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [fixedChatMode, setFixedChatMode] = useState(() => {\n if (typeof window === 'undefined') return false\n const params = new URLSearchParams(window.location.search)\n return params.get('fixed_chat') === '1' || params.get('embed_chat') === '1'\n })\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n setFixedChatMode(params.get('fixed_chat') === '1' || params.get('embed_chat') === '1')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n if (fixedChatMode) return\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [fixedChatMode])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n const selectedTypingState = selectedTopicId ? typingByTopic[selectedTopicId] : undefined\n const pollSelectedMessages = shouldPollMobileMessages(selectedTypingState)\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: pollSelectedMessages ? 2000 : 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n let latestAgentMessage: { id: string; senderId: string; senderName?: string; ts: number } | null = null\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n const senderType = String(rec.sender_type || '').toLowerCase()\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (senderType === 'agent' && !progress) {\n const id = String(rec.message_id || rec.id || '')\n if (id && (!latestAgentMessage || ts >= latestAgentMessage.ts)) {\n latestAgentMessage = {\n id,\n senderId: String(rec.sender_id || selectedAgentId),\n senderName: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n ts,\n }\n }\n }\n if (!progress) continue\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (\n latestAgentMessage\n &&\n shouldCloseWaitingStatusFromAgentReply(nextState, latestAgentMessage, now, STATUS_STALE_MS)\n ) {\n nextState = appendTypingStatus(nextState, {\n agentId: latestAgentMessage.senderId,\n agentName: latestAgentMessage.senderName || displayName(agents.find((agent) => agent.agent_id === latestAgentMessage.senderId)),\n statusText: 'Agent 已回复',\n statusKind: 'response',\n adapter: nextState.adapter,\n ttlMs: COMPLETE_HOLD_MS,\n }, Math.max(now, latestAgentMessage.ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const rawType = String(rawEvent.type || '').toLowerCase()\n if (rawType === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n if (['task_status', 'task_update', 'run_status', 'agent_status'].includes(rawType)) {\n const rawTaskId = String(rawEvent.task_id || rawEvent.taskId || '').trim()\n const tid = String(rawEvent.topic_id || rawEvent.topicId || (rawTaskId && rawTaskId === selectedTopic?.task_id ? selectedTopicId : '')).trim()\n if (!tid) return\n const aid = String(rawEvent.agent_id || rawEvent.runner_agent_id || topicActorAgentId || selectedAgentId)\n const title = eventString(rawEvent, ['task_title', 'title', 'name']) || compactTopicTitle(selectedTopic)\n const status = eventString(rawEvent, ['status', 'task_status', 'state']) || rawType\n const statusText = statusTextFromTypingEvent(rawEvent) || taskStatusText(status, title) || 'Agent 状态更新'\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(labelForAgentInTopic(aid), roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText,\n statusKind: statusKindFromTypingEvent(rawEvent) || status,\n ttlMs: isTerminalStatusKind(status) ? COMPLETE_HOLD_MS : 120000,\n }, Date.now()),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgentId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (typing) {\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }\n if (!selectedTopic?.task_id) return null\n const status = String(selectedTopic.task_status || '').trim()\n const text = taskStatusText(status, compactTopicTitle(selectedTopic))\n if (!status || !text) return null\n const agentId = String(selectedTopic.runner_agent_id || topicActorAgentId || selectedAgentId || '').trim()\n const agent = agents.find((item) => item.agent_id === agentId) || topicActorAgent || selectedAgent\n const now = Date.now()\n return {\n agentId,\n agentName: displayName(agent) || 'Agent',\n statusText: text,\n statusKind: status,\n lines: [{ id: `${selectedTopicId}-${status}`, text, kind: status, ts: now }],\n startedAt: now,\n expiresAt: Number.MAX_SAFE_INTEGER,\n wsState,\n }\n }, [agents, selectedAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgent, topicActorAgentId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已投递,等待 Agent 执行',\n statusKind: 'accepted',\n ttlMs: 120000,\n }, Date.now()),\n }))\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n {!fixedChatMode && (\n \n )}\n
\n
\n \n
{compactTopicTitle(selectedTopic)}
\n
\n
\n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '选择 Agent'}\n {selectedTopicMeta && ·}\n {selectedTopicMeta && {selectedTopicMeta}}\n {!fixedChatMode && }\n
\n
\n {!fixedChatMode && (\n <>\n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n \n )}\n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {!fixedChatMode && isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n \n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {!fixedChatMode && selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {!fixedChatMode && settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "b5c209a64cccb4406846e070442a3f68cc25ff37": {"t": 1785773571.4135854, "n": 115356, "h": "eff48e2ffb3fe3c5", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n task_status?: string\n runner_agent_id?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype MobileRunStatus = {\n agentId: string\n agentName: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n lines: TypingState['statusLines']\n startedAt: number\n expiresAt: number\n wsState: string\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('todo')) return '待执行'\n if (normalized.includes('doing') || normalized.includes('running')) return '执行中'\n if (normalized.includes('review')) return '待验收'\n if (normalized.includes('blocked')) return '阻塞'\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction taskStatusText(status?: string, title?: string): string {\n const normalized = String(status || '').toLowerCase()\n const subject = title ? `「${title}」` : '当前任务'\n if (normalized === 'todo') return `${subject}已创建,等待 Agent 接收`\n if (normalized === 'doing') return `${subject}执行中`\n if (normalized === 'review') return `${subject}已完成执行,等待验收`\n if (normalized === 'done') return `${subject}已完成`\n if (normalized === 'blocked') return `${subject}已阻塞`\n if (normalized === 'cancelled') return `${subject}已取消`\n return normalized ? `${subject}状态:${normalized}` : ''\n}\n\nfunction isTerminalStatusKind(kind?: string): boolean {\n const normalized = String(kind || '').toLowerCase()\n return normalized.includes('done')\n || normalized.includes('complete')\n || normalized.includes('review')\n || normalized.includes('blocked')\n || normalized.includes('cancelled')\n || normalized.includes('error')\n || normalized.includes('fail')\n || normalized.includes('response')\n}\n\nfunction adapterStatusLabel(adapter?: string): string {\n const value = String(adapter || '').trim()\n if (value === 'claude-code') return 'Claude Code'\n if (value === 'codex') return 'Codex'\n if (value === 'gemini') return 'Gemini'\n return value\n}\n\nfunction MobileAgentRunStatusCard({ status, compact = false }: { status: MobileRunStatus; compact?: boolean }) {\n const terminal = isTerminalStatusKind(status.statusKind)\n const lines = status.lines.slice(compact ? -4 : -6)\n const subtitle = [adapterStatusLabel(status.adapter), status.model].filter(Boolean).join(' · ')\n\n return (\n
\n
\n \n
\n
\n {status.agentName}\n \n {mobileStatusKindLabel(status.statusKind)}\n \n {subtitle && {subtitle}}\n WS {status.wsState}\n
\n

{status.statusText}

\n
\n
\n {lines.length > 0 && (\n
\n {lines.map((line) => (\n
\n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [fixedChatMode, setFixedChatMode] = useState(() => {\n if (typeof window === 'undefined') return false\n const params = new URLSearchParams(window.location.search)\n return params.get('fixed_chat') === '1' || params.get('embed_chat') === '1'\n })\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n setFixedChatMode(params.get('fixed_chat') === '1' || params.get('embed_chat') === '1')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n if (fixedChatMode) return\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [fixedChatMode])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const rawType = String(rawEvent.type || '').toLowerCase()\n if (rawType === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n if (['task_status', 'task_update', 'run_status', 'agent_status'].includes(rawType)) {\n const rawTaskId = String(rawEvent.task_id || rawEvent.taskId || '').trim()\n const tid = String(rawEvent.topic_id || rawEvent.topicId || (rawTaskId && rawTaskId === selectedTopic?.task_id ? selectedTopicId : '')).trim()\n if (!tid) return\n const aid = String(rawEvent.agent_id || rawEvent.runner_agent_id || topicActorAgentId || selectedAgentId)\n const title = eventString(rawEvent, ['task_title', 'title', 'name']) || compactTopicTitle(selectedTopic)\n const status = eventString(rawEvent, ['status', 'task_status', 'state']) || rawType\n const statusText = statusTextFromTypingEvent(rawEvent) || taskStatusText(status, title) || 'Agent 状态更新'\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(labelForAgentInTopic(aid), roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText,\n statusKind: statusKindFromTypingEvent(rawEvent) || status,\n ttlMs: isTerminalStatusKind(status) ? COMPLETE_HOLD_MS : 120000,\n }, Date.now()),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgentId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (typing) {\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }\n if (!selectedTopic?.task_id) return null\n const status = String(selectedTopic.task_status || '').trim()\n const text = taskStatusText(status, compactTopicTitle(selectedTopic))\n if (!status || !text) return null\n const agentId = String(selectedTopic.runner_agent_id || topicActorAgentId || selectedAgentId || '').trim()\n const agent = agents.find((item) => item.agent_id === agentId) || topicActorAgent || selectedAgent\n const now = Date.now()\n return {\n agentId,\n agentName: displayName(agent) || 'Agent',\n statusText: text,\n statusKind: status,\n lines: [{ id: `${selectedTopicId}-${status}`, text, kind: status, ts: now }],\n startedAt: now,\n expiresAt: Number.MAX_SAFE_INTEGER,\n wsState,\n }\n }, [agents, selectedAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgent, topicActorAgentId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n {!fixedChatMode && (\n \n )}\n
\n
\n \n
{compactTopicTitle(selectedTopic)}
\n
\n
\n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '选择 Agent'}\n {selectedTopicMeta && ·}\n {selectedTopicMeta && {selectedTopicMeta}}\n {!fixedChatMode && }\n
\n
\n {!fixedChatMode && (\n <>\n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n \n )}\n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {!fixedChatMode && isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n \n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {!fixedChatMode && selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {!fixedChatMode && settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "be389b061c0de52379d82f286a1c5bd0a2b67b3a": {"t": 1785773575.2483099, "n": 115576, "h": "962a3bd23af1bf07", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n task_status?: string\n runner_agent_id?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype MobileRunStatus = {\n agentId: string\n agentName: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n lines: TypingState['statusLines']\n startedAt: number\n expiresAt: number\n wsState: string\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('todo')) return '待执行'\n if (normalized.includes('doing') || normalized.includes('running')) return '执行中'\n if (normalized.includes('review')) return '待验收'\n if (normalized.includes('blocked')) return '阻塞'\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction taskStatusText(status?: string, title?: string): string {\n const normalized = String(status || '').toLowerCase()\n const subject = title ? `「${title}」` : '当前任务'\n if (normalized === 'todo') return `${subject}已创建,等待 Agent 接收`\n if (normalized === 'doing') return `${subject}执行中`\n if (normalized === 'review') return `${subject}已完成执行,等待验收`\n if (normalized === 'done') return `${subject}已完成`\n if (normalized === 'blocked') return `${subject}已阻塞`\n if (normalized === 'cancelled') return `${subject}已取消`\n return normalized ? `${subject}状态:${normalized}` : ''\n}\n\nfunction isTerminalStatusKind(kind?: string): boolean {\n const normalized = String(kind || '').toLowerCase()\n return normalized.includes('done')\n || normalized.includes('complete')\n || normalized.includes('review')\n || normalized.includes('blocked')\n || normalized.includes('cancelled')\n || normalized.includes('error')\n || normalized.includes('fail')\n || normalized.includes('response')\n}\n\nfunction adapterStatusLabel(adapter?: string): string {\n const value = String(adapter || '').trim()\n if (value === 'claude-code') return 'Claude Code'\n if (value === 'codex') return 'Codex'\n if (value === 'gemini') return 'Gemini'\n return value\n}\n\nfunction MobileAgentRunStatusCard({ status, compact = false }: { status: MobileRunStatus; compact?: boolean }) {\n const terminal = isTerminalStatusKind(status.statusKind)\n const lines = status.lines.slice(compact ? -4 : -6)\n const subtitle = [adapterStatusLabel(status.adapter), status.model].filter(Boolean).join(' · ')\n\n return (\n
\n
\n \n
\n
\n {status.agentName}\n \n {mobileStatusKindLabel(status.statusKind)}\n \n {subtitle && {subtitle}}\n WS {status.wsState}\n
\n

{status.statusText}

\n
\n
\n {lines.length > 0 && (\n
\n {lines.map((line) => (\n
\n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [fixedChatMode, setFixedChatMode] = useState(() => {\n if (typeof window === 'undefined') return false\n const params = new URLSearchParams(window.location.search)\n return params.get('fixed_chat') === '1' || params.get('embed_chat') === '1'\n })\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n setFixedChatMode(params.get('fixed_chat') === '1' || params.get('embed_chat') === '1')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n if (fixedChatMode) return\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [fixedChatMode])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n const rawType = String(rawEvent.type || '').toLowerCase()\n if (rawType === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n if (['task_status', 'task_update', 'run_status', 'agent_status'].includes(rawType)) {\n const rawTaskId = String(rawEvent.task_id || rawEvent.taskId || '').trim()\n const tid = String(rawEvent.topic_id || rawEvent.topicId || (rawTaskId && rawTaskId === selectedTopic?.task_id ? selectedTopicId : '')).trim()\n if (!tid) return\n const aid = String(rawEvent.agent_id || rawEvent.runner_agent_id || topicActorAgentId || selectedAgentId)\n const title = eventString(rawEvent, ['task_title', 'title', 'name']) || compactTopicTitle(selectedTopic)\n const status = eventString(rawEvent, ['status', 'task_status', 'state']) || rawType\n const statusText = statusTextFromTypingEvent(rawEvent) || taskStatusText(status, title) || 'Agent 状态更新'\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(labelForAgentInTopic(aid), roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText,\n statusKind: statusKindFromTypingEvent(rawEvent) || status,\n ttlMs: isTerminalStatusKind(status) ? COMPLETE_HOLD_MS : 120000,\n }, Date.now()),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgentId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (typing) {\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }\n if (!selectedTopic?.task_id) return null\n const status = String(selectedTopic.task_status || '').trim()\n const text = taskStatusText(status, compactTopicTitle(selectedTopic))\n if (!status || !text) return null\n const agentId = String(selectedTopic.runner_agent_id || topicActorAgentId || selectedAgentId || '').trim()\n const agent = agents.find((item) => item.agent_id === agentId) || topicActorAgent || selectedAgent\n const now = Date.now()\n return {\n agentId,\n agentName: displayName(agent) || 'Agent',\n statusText: text,\n statusKind: status,\n lines: [{ id: `${selectedTopicId}-${status}`, text, kind: status, ts: now }],\n startedAt: now,\n expiresAt: Number.MAX_SAFE_INTEGER,\n wsState,\n }\n }, [agents, selectedAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgent, topicActorAgentId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n {!fixedChatMode && (\n \n )}\n
\n
\n \n
{compactTopicTitle(selectedTopic)}
\n
\n
\n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '选择 Agent'}\n {selectedTopicMeta && ·}\n {selectedTopicMeta && {selectedTopicMeta}}\n {!fixedChatMode && }\n
\n
\n {!fixedChatMode && (\n <>\n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n \n )}\n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {fixedChatMode && selectedTopicRunStatus && (\n
\n \n
\n )}\n\n {!fixedChatMode && isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {!fixedChatMode && selectedTopicRunStatus && (\n
\n \n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {!fixedChatMode && selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {!fixedChatMode && settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "47fa8230b95ddcb2c197f5dd54080385195e1372": {"t": 1785773576.8126361, "n": 113136, "h": "63e6d7f2fc3af4e7", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n task_status?: string\n runner_agent_id?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('todo')) return '待执行'\n if (normalized.includes('doing') || normalized.includes('running')) return '执行中'\n if (normalized.includes('review')) return '待验收'\n if (normalized.includes('blocked')) return '阻塞'\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction taskStatusText(status?: string, title?: string): string {\n const normalized = String(status || '').toLowerCase()\n const subject = title ? `「${title}」` : '当前任务'\n if (normalized === 'todo') return `${subject}已创建,等待 Agent 接收`\n if (normalized === 'doing') return `${subject}执行中`\n if (normalized === 'review') return `${subject}已完成执行,等待验收`\n if (normalized === 'done') return `${subject}已完成`\n if (normalized === 'blocked') return `${subject}已阻塞`\n if (normalized === 'cancelled') return `${subject}已取消`\n return normalized ? `${subject}状态:${normalized}` : ''\n}\n\nfunction isTerminalStatusKind(kind?: string): boolean {\n const normalized = String(kind || '').toLowerCase()\n return normalized.includes('done')\n || normalized.includes('complete')\n || normalized.includes('review')\n || normalized.includes('blocked')\n || normalized.includes('cancelled')\n || normalized.includes('error')\n || normalized.includes('fail')\n || normalized.includes('response')\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [fixedChatMode, setFixedChatMode] = useState(() => {\n if (typeof window === 'undefined') return false\n const params = new URLSearchParams(window.location.search)\n return params.get('fixed_chat') === '1' || params.get('embed_chat') === '1'\n })\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n setFixedChatMode(params.get('fixed_chat') === '1' || params.get('embed_chat') === '1')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n if (fixedChatMode) return\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [fixedChatMode])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (typing) {\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }\n if (!selectedTopic?.task_id) return null\n const status = String(selectedTopic.task_status || '').trim()\n const text = taskStatusText(status, compactTopicTitle(selectedTopic))\n if (!status || !text) return null\n const agentId = String(selectedTopic.runner_agent_id || topicActorAgentId || selectedAgentId || '').trim()\n const agent = agents.find((item) => item.agent_id === agentId) || topicActorAgent || selectedAgent\n const now = Date.now()\n return {\n agentId,\n agentName: displayName(agent) || 'Agent',\n statusText: text,\n statusKind: status,\n lines: [{ id: `${selectedTopicId}-${status}`, text, kind: status, ts: now }],\n startedAt: now,\n expiresAt: Number.MAX_SAFE_INTEGER,\n wsState,\n }\n }, [agents, selectedAgent, selectedAgentId, selectedTopic, selectedTopicId, topicActorAgent, topicActorAgentId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n {!fixedChatMode && (\n \n )}\n
\n
\n \n
{compactTopicTitle(selectedTopic)}
\n
\n
\n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '选择 Agent'}\n {selectedTopicMeta && ·}\n {selectedTopicMeta && {selectedTopicMeta}}\n {!fixedChatMode && }\n
\n
\n {!fixedChatMode && (\n <>\n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n \n )}\n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {!fixedChatMode && isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {!fixedChatMode && selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {!fixedChatMode && settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "ba17edea14cc7f31150c79b263752708661400c5": {"t": 1785773578.6482766, "n": 110999, "h": "b373c93662a85633", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [fixedChatMode, setFixedChatMode] = useState(() => {\n if (typeof window === 'undefined') return false\n const params = new URLSearchParams(window.location.search)\n return params.get('fixed_chat') === '1' || params.get('embed_chat') === '1'\n })\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n setFixedChatMode(params.get('fixed_chat') === '1' || params.get('embed_chat') === '1')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n if (fixedChatMode) return\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [fixedChatMode])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n {!fixedChatMode && (\n \n )}\n
\n
\n \n
{compactTopicTitle(selectedTopic)}
\n
\n
\n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '选择 Agent'}\n {selectedTopicMeta && ·}\n {selectedTopicMeta && {selectedTopicMeta}}\n {!fixedChatMode && }\n
\n
\n {!fixedChatMode && (\n <>\n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n \n )}\n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {!fixedChatMode && isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {!fixedChatMode && selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {!fixedChatMode && settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "ab31aab30d467f87926bac8c0f9f53c2d8e822fd": {"t": 1785773580.3552413, "n": 110390, "h": "389e0a98bf553173", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const sessionToken = session?.accessToken as string | undefined\n const [nativeAccessToken, setNativeAccessToken] = useState('')\n const token = sessionToken || nativeAccessToken || undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n try {\n const stored = window.localStorage.getItem('__WTT_NATIVE_ACCESS_TOKEN__') || ''\n if (stored) setNativeAccessToken(stored)\n } catch {\n setNativeAccessToken('')\n }\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated' && !nativeAccessToken) {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [nativeAccessToken, router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "d621aafe3d1ace478a801053e76a1f856bcd38bf": {"t": 1785773586.6333191, "n": 109990, "h": "c21960165198960a", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n mode?: 'local' | 'passthrough'\n skillId?: string\n source?: string\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const slashAgentId = topicActorAgentId || selectedAgentId\n const slashAgentAdapter = useMemo(() => {\n if (!slashAgentId) return ''\n const runtime = runtimeMap[slashAgentId]\n return String(runtime?.adapter || runtime?.provider || '').trim()\n }, [runtimeMap, slashAgentId])\n\n const { data: dynamicSlashRaw } = useSWR(\n token && slashAgentId ? ['mobile-slash-commands', token, slashAgentId, slashAgentAdapter] : null,\n async () => {\n const params = new URLSearchParams()\n if (slashAgentAdapter) params.set('adapter', slashAgentAdapter)\n const suffix = params.toString() ? `?${params.toString()}` : ''\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/${encodeURIComponent(slashAgentId)}/slash-commands${suffix}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n const data = await res.json()\n return Array.isArray(data?.commands) ? data.commands as Array> : []\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const dynamicSlashCommands = useMemo(() => {\n const raw = Array.isArray(dynamicSlashRaw) ? dynamicSlashRaw : []\n return raw\n .flatMap((item) => {\n const rawCmd = String(item.cmd || item.command || '').trim()\n const cmd = rawCmd.startsWith('/') ? rawCmd : rawCmd ? `/${rawCmd}` : ''\n if (!cmd || cmd === '/') return []\n return [{\n cmd,\n desc: String(item.desc || item.description || item.name || 'Agent skill command').trim(),\n family: 'Agent' as const,\n mode: 'passthrough' as const,\n skillId: String(item.skill_id || item.skillId || item.id || '').trim(),\n source: String(item.source || '').trim(),\n }]\n })\n }, [dynamicSlashRaw])\n\n const availableSlashCommands = useMemo(() => {\n const rows = [...MOBILE_SLASH_COMMANDS, ...dynamicSlashCommands]\n const seen = new Set()\n return rows.filter((command) => {\n const key = command.cmd.toLowerCase()\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n }, [dynamicSlashCommands])\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? availableSlashCommands.filter((command) => `${command.cmd} ${command.desc} ${command.family} ${command.source || ''}`.toLowerCase().includes(q))\n : availableSlashCommands\n return rows.slice(0, 10)\n }, [availableSlashCommands, slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n const isSlashCommand = content.trim().startsWith('/')\n const slashCommand = isSlashCommand ? content.trim().split(/\\s+/, 1)[0] || content.trim() : ''\n const dynamicSlash = slashCommand\n ? dynamicSlashCommands.find((command) => command.cmd.toLowerCase() === slashCommand.toLowerCase())\n : undefined\n const metadata: Record = {}\n if (isSlashCommand) {\n metadata.slash_type = 'agent_passthrough'\n metadata.slash_command = slashCommand\n if (dynamicSlash?.skillId) {\n metadata.command_family = 'skill'\n metadata.skill_id = dynamicSlash.skillId\n }\n if (!sourceTaskId && isGroupTopic(selectedTopic)) {\n metadata.command_scope = 'single_agent'\n metadata.command_target_agent_id = sourceAgentId\n }\n }\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n ...(Object.keys(metadata).length ? { metadata } : {}),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, dynamicSlashCommands, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "d099e26c098433ebbaeeffad9e1418cb4622726e": {"t": 1785773590.1074896, "n": 106694, "h": "6cfa49073814679b", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { attachmentMimeType } from '@/lib/media/mime'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const mimeType = attachmentMimeType(file)\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: mimeType, size: file.size }),\n })\n if (!sign.ok) throw new Error(`Sign failed: ${await sign.text()}`)\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload network failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', mimeType)\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(`Commit failed: ${await commit.text()}`)\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "c94f6315cf5c8e07ad623681aa9a417a2c7189e9": {"t": 1785773592.590719, "n": 106608, "h": "1605f6076a4614eb", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [uploadError, setUploadError] = useState('')\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n setUploadError('')\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n const message = error instanceof Error ? error.message : '上传失败'\n setUploadError(message)\n alert(message)\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const openFilePicker = useCallback((kind: 'file' | 'camera') => {\n setAttachOpen(false)\n requestAnimationFrame(() => {\n const input = kind === 'camera' ? cameraInputRef.current : fileInputRef.current\n input?.click()\n })\n }, [])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n \n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {uploadError && !uploading && (\n
\n 上传失败:{uploadError}\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "b2a4cc30e4b438f082201c7171f0342b4a6421eb": {"t": 1785773594.429283, "n": 105875, "h": "d3e4c0930f8723d7", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL, resolveWttUploadUrl } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', resolveWttUploadUrl(signed.upload_url))\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "8aea21e3e23cf2f2fca3ca5039f2c3b5c0abeae8": {"t": 1785773597.202628, "n": 105860, "h": "2c2bd3d00f140624", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype RecentTopicRecord = TopicRecord & {\n topic_name?: string\n last_message_preview?: string\n primary_agent_id?: string\n agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const { data: groupTopicsRaw, mutate: mutateGroupTopics } = useSWR(\n token ? ['mobile-group-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-groups`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 30000, revalidateOnFocus: true },\n )\n\n const { data: recentTopicsRaw, mutate: mutateRecentTopics } = useSWR(\n token ? ['mobile-recent-topics', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/my-recent?limit=10`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return { items: [] }\n return res.json() as Promise<{ items?: RecentTopicRecord[] }>\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const seen = new Set()\n const recentRows = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n const list = [\n ...(Array.isArray(topicsRaw) ? topicsRaw : []),\n ...(Array.isArray(groupTopicsRaw) ? groupTopicsRaw : []),\n ...recentRows,\n ]\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .filter((topic) => {\n const id = topicId(topic)\n if (!id || seen.has(id)) return false\n seen.add(id)\n return true\n })\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [groupTopicsRaw, optimisticTaskTitles, recentTopicsRaw, topicsRaw])\n\n const recentTopics = useMemo(() => {\n const items = Array.isArray(recentTopicsRaw?.items) ? recentTopicsRaw.items : []\n return items\n .filter((topic) => topicId(topic))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n .slice(0, 10)\n }, [recentTopicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n const updateList = (current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }\n void mutateTopics(updateList, false)\n void mutateGroupTopics(updateList, false)\n void mutateRecentTopics((current?: { items?: RecentTopicRecord[] }) => {\n if (!current || !Array.isArray(current.items)) return current\n const next = updateList(current.items)\n return next === current.items ? current : { ...current, items: next as RecentTopicRecord[] }\n }, false)\n }, [mutateGroupTopics, mutateRecentTopics, mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) {\n await Promise.allSettled([mutateTopics(), mutateGroupTopics(), mutateRecentTopics()])\n } else {\n void mutateRecentTopics()\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, labelForAgentInTopic, mutateGroupTopics, mutateMessages, mutateRecentTopics, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n
\n
\n \n \n Recent\n \n {recentTopics.length}\n
\n
\n {recentTopics.length === 0 ? (\n
暂无最近对话
\n ) : recentTopics.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const agentLabel = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || agents.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n || primaryAgentId\n return (\n {\n if (primaryAgentId && primaryAgentId !== selectedAgentId) setSelectedAgentId(primaryAgentId)\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-sky-600 bg-white' : 'border-sky-100 bg-white/75'}`}\n >\n
\n \n {topic.topic_name || compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {[agentLabel, topic.last_message_preview || topicKindLabel(topic)].filter(Boolean).join(' · ')}\n
\n \n )\n })}\n
\n
\n\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "59a5b466db7e700c74310de74b62c8e0e98acf2a": {"t": 1785773599.9807155, "n": 100380, "h": "8baa7fcf26d54722", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 24 * 60 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : [])\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [optimisticTaskTitles, topicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, labelForAgentInTopic, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "785c119a5989ddea07dbbd873b2619e0b5180b6f": {"t": 1785773601.8248076, "n": 100370, "h": "9a139394d84a3632", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_title?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const taskTitle = String(topic?.task_title || '').trim()\n if (topic?.task_id && taskTitle) return taskTitle\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const title = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .filter(Boolean)\n .slice(0, 3)\n .join(' ')\n if (!title) return 'New Task'\n return title.length > 48 ? `${title.slice(0, 46)}...` : title\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [createdTaskIdsByTopic, setCreatedTaskIdsByTopic] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : [])\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n task_title: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [optimisticTaskTitles, topicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id\n ? String(selectedTopic.task_id)\n : (selectedTopicId ? createdTaskIdsByTopic[selectedTopicId] || '' : '')\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, labelForAgentInTopic, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) {\n pendingRenameTaskRef.current = { taskId, topicId: id }\n setCreatedTaskIdsByTopic((current) => ({ ...current, [id]: taskId }))\n }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "069aa5b95e9d38b2137662874267ea5c5f2ee3e8": {"t": 1785773603.440048, "n": 99884, "h": "6a85607871a58ce7", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const line = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .find(Boolean)\n if (!line) return 'New Task'\n return line.length > 36 ? `${line.slice(0, 34)}...` : line\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({\n href,\n children,\n onImageOpen,\n}: {\n href?: string\n children?: React.ReactNode\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n onImageOpen(imageUrl, meta.name)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${meta.name}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({\n src,\n alt,\n onImageOpen,\n}: {\n src?: string\n alt?: string\n onImageOpen: (url: string, label: string) => void\n}) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n const label = alt || filenameFromUrl(imageUrl) || 'image'\n return (\n onImageOpen(imageUrl, label)}\n className=\"my-2 inline-flex max-w-[144px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white text-left align-top shadow-sm\"\n aria-label={`查看原图 ${label}`}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {label}\n {label && {label}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const [imagePreview, setImagePreview] = useState<{ url: string; label: string } | null>(null)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [])\n\n const openImagePreview = useCallback((url: string, label: string) => {\n const imageUrl = proxyMediaUrl(url)\n if (!imageUrl) return\n setImagePreview({ url: imageUrl, label: label || filenameFromUrl(imageUrl) || 'image' })\n }, [])\n\n const mobileMarkdownComponents = useMemo(() => ({\n a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n {children}\n ),\n img: ({ src, alt }: { src?: string; alt?: string }) => (\n \n ),\n }), [openImagePreview])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : [])\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [optimisticTaskTitles, topicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id ? String(selectedTopic.task_id) : ''\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, labelForAgentInTopic, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) pendingRenameTaskRef.current = { taskId, topicId: id }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n \n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {imagePreview && (\n \n
\n
{imagePreview.label}
\n setImagePreview(null)}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-white/10 text-white\"\n aria-label=\"关闭图片预览\"\n >\n \n \n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n {`${imagePreview.label}\n
\n
\n )}\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n \n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "d8ac421e19c1ea985d114ba7e59be112b15894e1": {"t": 1785773604.99935, "n": 97410, "h": "a95748e24c10b6b7", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { proxyMediaUrl, toThumbnailUrl } from '@/lib/rich-content'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const line = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .find(Boolean)\n if (!line) return 'New Task'\n return line.length > 36 ? `${line.slice(0, 34)}...` : line\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction isMobileImageCandidate(label: string, url: string): boolean {\n const candidate = `${label} ${url}`.toLowerCase()\n return /(^|\\.)((png|jpe?g|gif|webp|heic|heif|bmp|avif))(\\?|#|\\s|$)/i.test(candidate)\n}\n\nfunction isImageFile(file: File): boolean {\n return file.type.startsWith('image/') || isMobileImageCandidate(file.name, file.name)\n}\n\nfunction isAudioFile(file: File): boolean {\n return file.type.startsWith('audio/') || /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))$/i.test(file.name)\n}\n\nfunction isVideoFile(file: File): boolean {\n return file.type.startsWith('video/') || /(^|\\.)((mp4|webm|mov|m4v))$/i.test(file.name)\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (isMobileImageCandidate(name, href)) return { isAttachment: true, kind: 'IMAGE', name }\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({ href, children }: { href?: string; children?: React.ReactNode }) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n if (meta.kind === 'IMAGE') {\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n {meta.name}\n {meta.name}\n \n )\n }\n const fileUrl = proxyMediaUrl(url)\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({ src, alt }: { src?: string; alt?: string }) {\n const url = String(src || '')\n const imageUrl = proxyMediaUrl(url)\n const thumbUrl = toThumbnailUrl(url)\n return (\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n {alt\n {alt && {alt}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : [])\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [optimisticTaskTitles, topicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id ? String(selectedTopic.task_id) : ''\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, labelForAgentInTopic, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) pendingRenameTaskRef.current = { taskId, topicId: id }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = isImageFile(file)\n const isAudio = isAudioFile(file)\n const isVideo = isVideoFile(file)\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: proxyMediaUrl(String(asset.url || '')), filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n // eslint-disable-next-line @next/next/no-img-element\n \"\"\n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n
\n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "59b40f7a42776521ac7c5004b4683396698d423b": {"t": 1785773608.320474, "n": 95781, "h": "5751db94c638a142", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { ArrowLeft, Bot, Camera, ChevronDown, ChevronRight, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Server, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype SelectorStep = 'hosts' | 'agents' | 'topics'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const line = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .find(Boolean)\n if (!line) return 'New Task'\n return line.length > 36 ? `${line.slice(0, 34)}...` : line\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({ href, children }: { href?: string; children?: React.ReactNode }) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({ src, alt }: { src?: string; alt?: string }) {\n const url = String(src || '')\n return (\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n {alt\n {alt && {alt}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [selectorStep, setSelectorStep] = useState('hosts')\n const [selectedHost, setSelectedHost] = useState('')\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const openSelector = useCallback(() => {\n setSearch('')\n setSelectorStep('hosts')\n setSelectedHost('')\n setSelectorOpen(true)\n }, [])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : [])\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [optimisticTaskTitles, topicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id ? String(selectedTopic.task_id) : ''\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const selectedAgentHost = useMemo(\n () => selectedAgent ? runtimeHostLabel(selectedAgent, runtimeMap[selectedAgent.agent_id]) : '',\n [runtimeMap, selectedAgent],\n )\n\n const activeHost = selectedHost || selectedAgentHost\n const activeHostGroup = useMemo(\n () => groupedAgents.find((group) => group.host === activeHost) || null,\n [activeHost, groupedAgents],\n )\n\n const selectorTitle = selectorStep === 'hosts'\n ? '选择主机'\n : selectorStep === 'agents'\n ? '选择 Agent'\n : '选择 Topic'\n\n const selectorSearchPlaceholder = selectorStep === 'hosts'\n ? '搜索主机或 Agent'\n : selectorStep === 'agents'\n ? '搜索当前主机下的 Agent'\n : '搜索当前 Agent 的 Topic'\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, labelForAgentInTopic, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) pendingRenameTaskRef.current = { taskId, topicId: id }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = file.type.startsWith('image/')\n const isAudio = file.type.startsWith('audio/')\n const isVideo = file.type.startsWith('video/')\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: asset.url, filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n // eslint-disable-next-line @next/next/no-img-element\n \"\"\n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder={selectorSearchPlaceholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n {selectorStep === 'hosts' && (\n
\n
\n 先选择运行 Agent 的主机,再选择该主机下的 Agent,最后进入它的 Topic。\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n const active = group.host === selectedAgentHost\n return (\n {\n setSelectedHost(group.host)\n setSearch('')\n setSelectorStep('agents')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {group.host}\n {online} 在线 · {group.rows.length} 个 Agent\n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'agents' && (\n
\n {\n setSearch('')\n setSelectorStep('hosts')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回主机\n \n
\n \n {activeHost || '选择主机'}\n {activeHostGroup?.rows.length || 0}\n
\n
\n {!activeHostGroup ? (\n
当前主机下没有匹配的 Agent。
\n ) : activeHostGroup.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n setSearch('')\n setSelectorStep('topics')\n }}\n className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left ${active ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-white'}`}\n >\n \n \n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) || compactId(agent.agent_id, 10, 4)}\n \n \n \n \n )\n })}\n
\n
\n )}\n\n {selectorStep === 'topics' && (\n
\n {\n setSearch('')\n setSelectedHost(selectedAgentHost)\n setSelectorStep('agents')\n }}\n className=\"mb-2 inline-flex items-center gap-1.5 rounded-xl bg-slate-100 px-3 py-2 text-xs font-semibold text-slate-600\"\n >\n \n 返回 Agent\n \n
\n
\n \n \n {selectedAgent ? labelForAgentInTopic(selectedAgent.agent_id, compactAgentName(selectedAgent)) : '未选择 Agent'}\n \n \n
\n
{selectedAgentHost || '未上报主机'}
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n )}\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n
\n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "24a8168b8e3fd6eb6864b74443af05d5a95eb6d9": {"t": 1785773609.8688374, "n": 91079, "h": "505bd8d4e7352317", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { Bot, Camera, ChevronDown, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { shouldHideFeedTopic } from '@/lib/feed-topic-filter'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const line = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .find(Boolean)\n if (!line) return 'New Task'\n return line.length > 36 ? `${line.slice(0, 34)}...` : line\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({ href, children }: { href?: string; children?: React.ReactNode }) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({ src, alt }: { src?: string; alt?: string }) {\n const url = String(src || '')\n return (\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n {alt\n {alt && {alt}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : [])\n .filter((t) => topicId(t))\n .filter((topic) => !shouldHideFeedTopic(topic as Record))\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [optimisticTaskTitles, topicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id ? String(selectedTopic.task_id) : ''\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, labelForAgentInTopic, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) pendingRenameTaskRef.current = { taskId, topicId: id }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = file.type.startsWith('image/')\n const isAudio = file.type.startsWith('audio/')\n const isVideo = file.type.startsWith('video/')\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: asset.url, filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n // eslint-disable-next-line @next/next/no-img-element\n \"\"\n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder=\"搜索 Agent / Topic / 群聊\"\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n
\n
\n \n 主机目录\n {agents.length}\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n return (\n
\n
\n {group.host}\n {online}/{group.rows.length}\n
\n
\n {group.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n }}\n className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${active ? 'bg-[#0d0d0d] text-white' : 'bg-white text-slate-700'}`}\n >\n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) && {runtimeLine(runtime)}}\n \n \n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n
\n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "1138d7a7cca6144715827db7d4cb4c47c6d1d4b8": {"t": 1785773612.2191403, "n": 90929, "h": "01840eb700006bdf", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { Bot, Camera, ChevronDown, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n roleLabel?: string\n role_label?: string\n role_name?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction appendRoleLabel(label: string, roleLabel?: string): string {\n const base = String(label || '').trim()\n const role = String(roleLabel || '').trim()\n if (!base || !role) return base\n if (base.includes(`(${role})`) || base.includes(`(${role})`)) return base\n return `${base}(${role})`\n}\n\nfunction topicMemberRole(member?: TopicMember | null): string {\n return String(member?.roleLabel || member?.role_label || member?.role_name || member?.role || '').trim()\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const line = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .find(Boolean)\n if (!line) return 'New Task'\n return line.length > 36 ? `${line.slice(0, 34)}...` : line\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({ href, children }: { href?: string; children?: React.ReactNode }) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({ src, alt }: { src?: string; alt?: string }) {\n const url = String(src || '')\n return (\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n {alt\n {alt && {alt}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : []).filter((t) => topicId(t))\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [optimisticTaskTitles, topicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id ? String(selectedTopic.task_id) : ''\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const roleLabelForAgent = useCallback((agentId?: string) => {\n const id = String(agentId || '').trim()\n if (!id) return ''\n return topicMemberRole(selectedTopicMembers.find((member) => member.agent_id === id))\n }, [selectedTopicMembers])\n\n const labelForAgentInTopic = useCallback((agentId: string, fallbackLabel?: string) => {\n const id = String(agentId || '').trim()\n const agent = agents.find((item) => item.agent_id === id)\n const base = String(fallbackLabel || '').trim()\n || selectedTopicMembers.find((member) => member.agent_id === id)?.display_name\n || (agent ? displayName(agent) : '')\n || compactId(id, 9, 4)\n return appendRoleLabel(base, roleLabelForAgent(id))\n }, [agents, roleLabelForAgent, selectedTopicMembers])\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', displayLabel: 'all(所有成员)', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const role = topicMemberRole(member)\n const meta = role || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: appendRoleLabel(label, role), meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, displayLabel: labelForAgentInTopic(agentId, label), meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n const label = displayName(agent)\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label,\n displayLabel: labelForAgentInTopic(agent.agent_id, label),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, labelForAgentInTopic, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.displayLabel} ${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n const agentName = String(rawEvent.agent_display_name || '') || labelForAgentInTopic(aid)\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: appendRoleLabel(agentName, roleLabelForAgent(aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : labelForAgentInTopic(senderId)\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: appendRoleLabel(senderDisplayName, roleLabelForAgent(senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [labelForAgentInTopic, mutateMessages, roleLabelForAgent, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: labelForAgentInTopic(sourceAgentId, displayName(sourceAgent)),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, labelForAgentInTopic, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) pendingRenameTaskRef.current = { taskId, topicId: id }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = file.type.startsWith('image/')\n const isAudio = file.type.startsWith('audio/')\n const isVideo = file.type.startsWith('video/')\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: asset.url, filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {appendRoleLabel(label, topicMemberRole(member))}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const baseLabel = senderLabel(message)\n const label = isMine ? baseLabel : appendRoleLabel(baseLabel, roleLabelForAgent(message.sender_id))\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {appendRoleLabel(selectedTopicRunStatus.agentName, roleLabelForAgent(selectedTopicRunStatus.agentId))}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n // eslint-disable-next-line @next/next/no-img-element\n \"\"\n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.displayLabel}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder=\"搜索 Agent / Topic / 群聊\"\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n
\n
\n \n 主机目录\n {agents.length}\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n return (\n
\n
\n {group.host}\n {online}/{group.rows.length}\n
\n
\n {group.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n }}\n className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${active ? 'bg-[#0d0d0d] text-white' : 'bg-white text-slate-700'}`}\n >\n \n \n {labelForAgentInTopic(agent.agent_id, compactAgentName(agent))}\n {runtimeLine(runtime) && {runtimeLine(runtime)}}\n \n \n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n
\n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "5279fdab2736e2ff69f1570e0b2a8b54b3668b9b": {"t": 1785773614.8452864, "n": 88870, "h": "a61138dfb1d7dadc", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { Bot, Camera, ChevronDown, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype MobileSlashCommand = {\n cmd: string\n desc: string\n family: 'WTT' | 'Codex' | 'Claude' | 'Gemini' | 'Agent'\n}\n\nconst MOBILE_SLASH_COMMANDS: MobileSlashCommand[] = [\n { cmd: '/new task', desc: '创建普通任务', family: 'WTT' },\n { cmd: '/new code task', desc: '创建代码任务', family: 'WTT' },\n { cmd: '/new research task', desc: '创建研究任务', family: 'WTT' },\n { cmd: '/new topic', desc: '创建 Topic', family: 'WTT' },\n { cmd: '/run', desc: '运行当前任务', family: 'WTT' },\n { cmd: '/workers', desc: '查看 Agent workers', family: 'WTT' },\n { cmd: '/status', desc: '查看运行状态', family: 'Agent' },\n { cmd: '/help', desc: '查看帮助', family: 'Agent' },\n { cmd: '/model', desc: '查看或切换模型', family: 'Agent' },\n { cmd: '/compact', desc: '压缩上下文', family: 'Agent' },\n { cmd: '/clear', desc: '清空当前会话视图', family: 'Agent' },\n { cmd: '/new', desc: '开启新运行时会话', family: 'Agent' },\n { cmd: '/review', desc: '审查当前修改', family: 'Codex' },\n { cmd: '/init', desc: '初始化项目上下文', family: 'Codex' },\n { cmd: '/approvals', desc: '查看审批策略', family: 'Codex' },\n { cmd: '/permissions', desc: '查看权限设置', family: 'Codex' },\n { cmd: '/diff', desc: '查看当前 diff', family: 'Codex' },\n { cmd: '/mcp', desc: '查看 MCP 工具', family: 'Codex' },\n { cmd: '/agents', desc: '管理子 Agent', family: 'Claude' },\n { cmd: '/config', desc: '运行时配置', family: 'Claude' },\n { cmd: '/memory', desc: '查看或管理记忆', family: 'Claude' },\n { cmd: '/cost', desc: '查看用量成本', family: 'Claude' },\n { cmd: '/tools', desc: '查看可用工具', family: 'Gemini' },\n { cmd: '/stats', desc: '查看会话统计', family: 'Gemini' },\n { cmd: '/resume', desc: '恢复会话', family: 'Agent' },\n { cmd: '/exit', desc: '退出或断开运行时', family: 'Agent' },\n]\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const line = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .find(Boolean)\n if (!line) return 'New Task'\n return line.length > 36 ? `${line.slice(0, 34)}...` : line\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({ href, children }: { href?: string; children?: React.ReactNode }) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({ src, alt }: { src?: string; alt?: string }) {\n const url = String(src || '')\n return (\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n {alt\n {alt && {alt}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [slashOpen, setSlashOpen] = useState(false)\n const [slashFilter, setSlashFilter] = useState('')\n const [slashIndex, setSlashIndex] = useState(0)\n const [mentionOpen, setMentionOpen] = useState(false)\n const [mentionQuery, setMentionQuery] = useState('')\n const [mentionIndex, setMentionIndex] = useState(0)\n const [mentionStartPos, setMentionStartPos] = useState(-1)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : []).filter((t) => topicId(t))\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [optimisticTaskTitles, topicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id ? String(selectedTopic.task_id) : ''\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n const mentionCandidates = useMemo(() => {\n const rows = new Map()\n const canMentionAll = Boolean(selectedTopic && isGroupTopic(selectedTopic))\n if (canMentionAll) rows.set('__all__', { agentId: '__all__', label: 'all', meta: '所有成员' })\n\n for (const member of selectedTopicMembers) {\n const agentId = String(member.agent_id || '').trim()\n if (!agentId) continue\n const label = String(member.display_name || '').trim() || compactId(agentId, 9, 4)\n const meta = String(member.role || '').trim() || compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, meta })\n }\n\n for (const id of selectedTopic?.member_agent_ids || []) {\n const agentId = String(id || '').trim()\n if (!agentId || rows.has(agentId)) continue\n const agent = agents.find((item) => item.agent_id === agentId)\n const label = agent ? displayName(agent) : compactId(agentId, 9, 4)\n rows.set(agentId, { agentId, label, meta: compactId(agentId, 9, 4) })\n }\n\n for (const agent of agents) {\n if (!agent.agent_id || rows.has(agent.agent_id)) continue\n rows.set(agent.agent_id, {\n agentId: agent.agent_id,\n label: displayName(agent),\n meta: runtimeHostLabel(agent, runtimeMap[agent.agent_id]),\n })\n }\n\n return Array.from(rows.values())\n }, [agents, runtimeMap, selectedTopic, selectedTopicMembers])\n\n const filteredMentions = useMemo(() => {\n const q = mentionQuery.trim().toLowerCase()\n if (!q) return mentionCandidates.slice(0, 10)\n return mentionCandidates\n .filter((candidate) => `${candidate.label} ${candidate.agentId} ${candidate.meta}`.toLowerCase().includes(q))\n .slice(0, 10)\n }, [mentionCandidates, mentionQuery])\n\n const filteredSlashCommands = useMemo(() => {\n const q = slashFilter.trim().toLowerCase()\n const rows = q\n ? MOBILE_SLASH_COMMANDS.filter((command) => `${command.cmd} ${command.desc} ${command.family}`.toLowerCase().includes(q))\n : MOBILE_SLASH_COMMANDS\n return rows.slice(0, 10)\n }, [slashFilter])\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: String(rawEvent.agent_display_name || '') || displayName(agents.find((a) => a.agent_id === aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : undefined\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: senderDisplayName || displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [agents, mutateMessages, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n useEffect(() => {\n setSlashIndex((index) => Math.min(index, Math.max(filteredSlashCommands.length - 1, 0)))\n }, [filteredSlashCommands.length])\n\n useEffect(() => {\n setMentionIndex((index) => Math.min(index, Math.max(filteredMentions.length - 1, 0)))\n }, [filteredMentions.length])\n\n useEffect(() => {\n setSlashOpen(false)\n setMentionOpen(false)\n setSlashFilter('')\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [selectedTopicId])\n\n const updateComposerDraft = useCallback((value: string, cursorPos?: number | null) => {\n setDraft(value)\n\n const cursor = typeof cursorPos === 'number' ? cursorPos : value.length\n const textUpToCursor = value.slice(0, cursor)\n const singleLine = !value.includes('\\n')\n const slashFilterText = singleLine && textUpToCursor.startsWith('/') ? textUpToCursor.trim() : ''\n if (slashFilterText) {\n setSlashOpen(true)\n setSlashFilter(slashFilterText)\n setSlashIndex(0)\n } else {\n setSlashOpen(false)\n setSlashFilter('')\n }\n\n const mentionMatch = textUpToCursor.match(/@([^\\s@]*)$/)\n if (mentionMatch && mentionCandidates.length > 0) {\n setMentionOpen(true)\n setMentionQuery(mentionMatch[1] || '')\n setMentionStartPos(cursor - mentionMatch[0].length)\n setMentionIndex(0)\n } else {\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }\n }, [mentionCandidates.length])\n\n const closeComposerSuggestions = useCallback(() => {\n setSlashOpen(false)\n setSlashFilter('')\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n }, [])\n\n const insertSlashCommand = useCallback((command: MobileSlashCommand) => {\n const next = `${command.cmd} `\n setDraft(next)\n setSlashOpen(false)\n setSlashFilter('')\n requestAnimationFrame(() => {\n composerRef.current?.focus()\n composerRef.current?.setSelectionRange(next.length, next.length)\n })\n }, [])\n\n const insertMention = useCallback((candidate: { agentId: string; label: string }) => {\n const textarea = composerRef.current\n if (!textarea || mentionStartPos < 0) return\n const cursor = textarea.selectionStart\n const before = draft.slice(0, mentionStartPos)\n const after = draft.slice(cursor)\n const mention = candidate.agentId === '__all__' ? '@all ' : `@${candidate.label} `\n const next = before + mention + after\n const nextCursor = before.length + mention.length\n setDraft(next)\n setMentionOpen(false)\n setMentionQuery('')\n setMentionStartPos(-1)\n requestAnimationFrame(() => {\n textarea.focus()\n textarea.setSelectionRange(nextCursor, nextCursor)\n })\n }, [draft, mentionStartPos])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n closeComposerSuggestions()\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: displayName(sourceAgent),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, closeComposerSuggestions, draft, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) pendingRenameTaskRef.current = { taskId, topicId: id }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n closeComposerSuggestions()\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [closeComposerSuggestions, creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = file.type.startsWith('image/')\n const isAudio = file.type.startsWith('audio/')\n const isVideo = file.type.startsWith('video/')\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: asset.url, filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {label}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const label = senderLabel(message)\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {selectedTopicRunStatus.agentName}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n // eslint-disable-next-line @next/next/no-img-element\n \"\"\n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n {slashOpen && filteredSlashCommands.length > 0 && (\n
\n {filteredSlashCommands.map((command, index) => (\n event.preventDefault()}\n onClick={() => insertSlashCommand(command)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === slashIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n {command.family}\n \n {command.cmd}\n {command.desc}\n \n \n ))}\n
\n )}\n {mentionOpen && filteredMentions.length > 0 && (\n
\n {filteredMentions.map((candidate, index) => (\n event.preventDefault()}\n onClick={() => insertMention(candidate)}\n className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${index === mentionIndex ? 'bg-slate-100' : 'hover:bg-slate-50'}`}\n >\n \n {candidate.label.slice(0, 1).toUpperCase()}\n \n \n @{candidate.label}\n {candidate.meta}\n \n \n ))}\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n updateComposerDraft(event.target.value, event.target.selectionStart)}\n onKeyDown={(event) => {\n if (mentionOpen && filteredMentions.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setMentionIndex((index) => (index + 1) % filteredMentions.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setMentionIndex((index) => (index - 1 + filteredMentions.length) % filteredMentions.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredMentions[mentionIndex]\n if (selected) insertMention(selected)\n return\n }\n if (event.key === 'Escape') {\n setMentionOpen(false)\n return\n }\n }\n if (slashOpen && filteredSlashCommands.length > 0) {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n setSlashIndex((index) => (index + 1) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n setSlashIndex((index) => (index - 1 + filteredSlashCommands.length) % filteredSlashCommands.length)\n return\n }\n if (event.key === 'Tab' || (event.key === 'Enter' && !event.shiftKey)) {\n event.preventDefault()\n const selected = filteredSlashCommands[slashIndex]\n if (selected) insertSlashCommand(selected)\n return\n }\n if (event.key === 'Escape') {\n setSlashOpen(false)\n return\n }\n }\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder=\"搜索 Agent / Topic / 群聊\"\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n
\n
\n \n 主机目录\n {agents.length}\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n return (\n
\n
\n {group.host}\n {online}/{group.rows.length}\n
\n
\n {group.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n }}\n className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${active ? 'bg-[#0d0d0d] text-white' : 'bg-white text-slate-700'}`}\n >\n \n \n {compactAgentName(agent)}\n {runtimeLine(runtime) && {runtimeLine(runtime)}}\n \n \n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n
\n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "26ba7ff23fa72927db1f7ca5c95a52787f22f56f": {"t": 1785773617.19098, "n": 77434, "h": "e86f6f752efff7c0", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { Bot, Camera, ChevronDown, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const line = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .find(Boolean)\n if (!line) return 'New Task'\n return line.length > 36 ? `${line.slice(0, 34)}...` : line\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({ href, children }: { href?: string; children?: React.ReactNode }) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({ src, alt }: { src?: string; alt?: string }) {\n const url = String(src || '')\n return (\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n {alt\n {alt && {alt}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const { data: agentsRaw } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : []).filter((t) => topicId(t))\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [optimisticTaskTitles, topicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id ? String(selectedTopic.task_id) : ''\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: String(rawEvent.agent_display_name || '') || displayName(agents.find((a) => a.agent_id === aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : undefined\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: senderDisplayName || displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [agents, mutateMessages, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: displayName(sourceAgent),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, draft, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) pendingRenameTaskRef.current = { taskId, topicId: id }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = file.type.startsWith('image/')\n const isAudio = file.type.startsWith('audio/')\n const isVideo = file.type.startsWith('video/')\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: asset.url, filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {label}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => {\n window.location.href = '/feed'\n }}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const label = senderLabel(message)\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {selectedTopicRunStatus.agentName}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n // eslint-disable-next-line @next/next/no-img-element\n \"\"\n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder=\"搜索 Agent / Topic / 群聊\"\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n
\n
\n \n 主机目录\n {agents.length}\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n return (\n
\n
\n {group.host}\n {online}/{group.rows.length}\n
\n
\n {group.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n }}\n className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${active ? 'bg-[#0d0d0d] text-white' : 'bg-white text-slate-700'}`}\n >\n \n \n {compactAgentName(agent)}\n {runtimeLine(runtime) && {runtimeLine(runtime)}}\n \n \n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n
\n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "5235984dc1a5a000189a98f6008f3b4f75769a29": {"t": 1785773618.8601034, "n": 85040, "h": "6791b26e5380a54d", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { Bot, Camera, ChevronDown, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\ntype ProvisionedAgent = {\n agent_id: string\n agent_token: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const line = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .find(Boolean)\n if (!line) return 'New Task'\n return line.length > 36 ? `${line.slice(0, 34)}...` : line\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({ href, children }: { href?: string; children?: React.ReactNode }) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({ src, alt }: { src?: string; alt?: string }) {\n const url = String(src || '')\n return (\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n {alt\n {alt && {alt}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [claimAgentId, setClaimAgentId] = useState('')\n const [claimAgentToken, setClaimAgentToken] = useState('')\n const [claimDisplayName, setClaimDisplayName] = useState('')\n const [claimingAgent, setClaimingAgent] = useState(false)\n const [claimAgentMessage, setClaimAgentMessage] = useState('')\n const [claimAgentError, setClaimAgentError] = useState('')\n const [provisionDisplayName, setProvisionDisplayName] = useState('')\n const [provisioningAgent, setProvisioningAgent] = useState(false)\n const [provisionedAgent, setProvisionedAgent] = useState(null)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const { data: agentsRaw, mutate: mutateAgents } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : []).filter((t) => topicId(t))\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [optimisticTaskTitles, topicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id ? String(selectedTopic.task_id) : ''\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: String(rawEvent.agent_display_name || '') || displayName(agents.find((a) => a.agent_id === aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : undefined\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: senderDisplayName || displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [agents, mutateMessages, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: displayName(sourceAgent),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n if (!sourceTaskId) await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, draft, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) pendingRenameTaskRef.current = { taskId, topicId: id }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const claimExistingAgent = useCallback(async () => {\n if (!token || claimingAgent) return\n const agentId = claimAgentId.trim()\n const agentToken = claimAgentToken.trim()\n if (!agentId || !agentToken) {\n setClaimAgentError('agent_id 和 agent_token 都不能为空')\n return\n }\n setClaimingAgent(true)\n setClaimAgentError('')\n setClaimAgentMessage('')\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/claim-existing`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n agent_id: agentId,\n agent_token: agentToken,\n display_name: claimDisplayName.trim() || undefined,\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(typeof data.detail === 'string' ? data.detail : '绑定 Agent 失败')\n }\n const newAgentId = String(data.agent_id || agentId).trim()\n setClaimAgentMessage('Agent 已绑定')\n setClaimAgentId('')\n setClaimAgentToken('')\n setClaimDisplayName('')\n await mutateAgents()\n if (newAgentId) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(newAgentId)\n setSelectedTopicId('')\n }\n } catch (error) {\n setClaimAgentError(error instanceof Error ? error.message : '绑定 Agent 失败')\n } finally {\n setClaimingAgent(false)\n }\n }, [claimAgentId, claimAgentToken, claimDisplayName, claimingAgent, mutateAgents, token])\n\n const provisionLocalAgent = useCallback(async () => {\n if (!token || provisioningAgent) return\n setProvisioningAgent(true)\n setClaimAgentError('')\n setClaimAgentMessage('')\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n display_name: provisionDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(typeof data.detail === 'string' ? data.detail : '生成 Agent 凭证失败')\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) throw new Error('后端未返回 agent_id 或 agent_token')\n setProvisionedAgent({ agent_id: agentId, agent_token: agentToken })\n setClaimAgentMessage('Agent 凭证已生成')\n setProvisionDisplayName('')\n await mutateAgents()\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentId)\n setSelectedTopicId('')\n } catch (error) {\n setClaimAgentError(error instanceof Error ? error.message : '生成 Agent 凭证失败')\n } finally {\n setProvisioningAgent(false)\n }\n }, [mutateAgents, provisionDisplayName, provisioningAgent, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = file.type.startsWith('image/')\n const isAudio = file.type.startsWith('audio/')\n const isVideo = file.type.startsWith('video/')\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: asset.url, filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {label}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => setSettingsOpen(true)}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const label = senderLabel(message)\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {selectedTopicRunStatus.agentName}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n // eslint-disable-next-line @next/next/no-img-element\n \"\"\n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n setAttachOpen(false)\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder=\"搜索 Agent / Topic / 群聊\"\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n
\n
\n \n 主机目录\n {agents.length}\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n return (\n
\n
\n {group.host}\n {online}/{group.rows.length}\n
\n
\n {group.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n }}\n className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${active ? 'bg-[#0d0d0d] text-white' : 'bg-white text-slate-700'}`}\n >\n \n \n {compactAgentName(agent)}\n {runtimeLine(runtime) && {runtimeLine(runtime)}}\n \n \n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n
\n

Agent 绑定

\n
\n

绑定已有 Agent

\n
\n setClaimAgentId(event.target.value)}\n placeholder=\"已有 agent_id\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n setClaimAgentToken(event.target.value)}\n placeholder=\"已有 agent_token\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n setClaimDisplayName(event.target.value)}\n placeholder=\"显示名称(可选)\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n void claimExistingAgent()}\n disabled={!claimAgentId.trim() || !claimAgentToken.trim() || claimingAgent}\n className=\"w-full rounded-2xl bg-[#0d0d0d] px-4 py-3 text-sm font-semibold text-white disabled:bg-slate-300\"\n >\n {claimingAgent ? '绑定中...' : '绑定已有 Agent'}\n \n
\n
\n\n
\n

生成本地 Agent

\n
\n setProvisionDisplayName(event.target.value)}\n placeholder=\"新 Agent 名称(可选)\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n void provisionLocalAgent()}\n disabled={provisioningAgent}\n className=\"w-full rounded-2xl bg-[#0d0d0d] px-4 py-3 text-sm font-semibold text-white disabled:bg-slate-300\"\n >\n {provisioningAgent ? '生成中...' : '生成本地 Agent 凭证'}\n \n
\n
\n\n {provisionedAgent && (\n
\n

凭证只展示在这里,请在本地主机执行:

\n \n
\n )}\n\n {claimAgentMessage &&

{claimAgentMessage}

}\n {claimAgentError &&

{claimAgentError}

}\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n
\n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "3d06af468f27398291ff637d166cbc533383b8c0": {"t": 1785773620.4410343, "n": 85592, "h": "f8d5f95d9bba306f", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { Bot, Camera, ChevronDown, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\nconst OPTIMISTIC_TASK_TITLE_TTL_MS = 30 * 60 * 1000\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype OptimisticTaskTitle = {\n title: string\n expiresAt: number\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\ntype ProvisionedAgent = {\n agent_id: string\n agent_token: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}$/i.test(name) || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const line = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .find(Boolean)\n if (!line) return 'New Task'\n return line.length > 36 ? `${line.slice(0, 34)}...` : line\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({ href, children }: { href?: string; children?: React.ReactNode }) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({ src, alt }: { src?: string; alt?: string }) {\n const url = String(src || '')\n return (\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n {alt\n {alt && {alt}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [optimisticTaskTitles, setOptimisticTaskTitles] = useState>({})\n const [claimAgentId, setClaimAgentId] = useState('')\n const [claimAgentToken, setClaimAgentToken] = useState('')\n const [claimDisplayName, setClaimDisplayName] = useState('')\n const [claimingAgent, setClaimingAgent] = useState(false)\n const [claimAgentMessage, setClaimAgentMessage] = useState('')\n const [claimAgentError, setClaimAgentError] = useState('')\n const [provisionDisplayName, setProvisionDisplayName] = useState('')\n const [provisioningAgent, setProvisioningAgent] = useState(false)\n const [provisionedAgent, setProvisionedAgent] = useState(null)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n const openFilePicker = useCallback((input: HTMLInputElement | null) => {\n if (!input) return\n input.click()\n setAttachOpen(false)\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const { data: agentsRaw, mutate: mutateAgents } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : []).filter((t) => topicId(t))\n const now = Date.now()\n return [...list]\n .map((topic) => {\n const id = topicId(topic)\n const optimistic = optimisticTaskTitles[id]\n if (!optimistic || optimistic.expiresAt <= now || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimistic.title,\n last_activity_at: topic.last_activity_at || new Date(now).toISOString(),\n }\n })\n .sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [optimisticTaskTitles, topicsRaw])\n\n useEffect(() => {\n if (!Object.keys(optimisticTaskTitles).length) return\n const now = Date.now()\n const rawList = Array.isArray(topicsRaw) ? topicsRaw : []\n setOptimisticTaskTitles((current) => {\n let changed = false\n const next: Record = {}\n for (const [id, optimistic] of Object.entries(current)) {\n const rawTopic = rawList.find((topic) => topicId(topic) === id)\n if (optimistic.expiresAt <= now || (rawTopic && !isDefaultTaskTitle(rawTopic))) {\n changed = true\n continue\n }\n next[id] = optimistic\n }\n return changed ? next : current\n })\n }, [optimisticTaskTitles, topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id ? String(selectedTopic.task_id) : ''\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: String(rawEvent.agent_display_name || '') || displayName(agents.find((a) => a.agent_id === aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : undefined\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: senderDisplayName || displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [agents, mutateMessages, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: displayName(sourceAgent),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n const expiresAt = Date.now() + OPTIMISTIC_TASK_TITLE_TTL_MS\n setOptimisticTaskTitles((current) => ({\n ...current,\n [sourceTopicId]: { title: optimisticTitle, expiresAt },\n }))\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n return current.map((topic) => {\n if (topicId(topic) !== sourceTopicId || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimisticTitle,\n last_activity_at: new Date().toISOString(),\n }\n })\n }, false)\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 1200)\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, draft, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) pendingRenameTaskRef.current = { taskId, topicId: id }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const claimExistingAgent = useCallback(async () => {\n if (!token || claimingAgent) return\n const agentId = claimAgentId.trim()\n const agentToken = claimAgentToken.trim()\n if (!agentId || !agentToken) {\n setClaimAgentError('agent_id 和 agent_token 都不能为空')\n return\n }\n setClaimingAgent(true)\n setClaimAgentError('')\n setClaimAgentMessage('')\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/claim-existing`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n agent_id: agentId,\n agent_token: agentToken,\n display_name: claimDisplayName.trim() || undefined,\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(typeof data.detail === 'string' ? data.detail : '绑定 Agent 失败')\n }\n const newAgentId = String(data.agent_id || agentId).trim()\n setClaimAgentMessage('Agent 已绑定')\n setClaimAgentId('')\n setClaimAgentToken('')\n setClaimDisplayName('')\n await mutateAgents()\n if (newAgentId) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(newAgentId)\n setSelectedTopicId('')\n }\n } catch (error) {\n setClaimAgentError(error instanceof Error ? error.message : '绑定 Agent 失败')\n } finally {\n setClaimingAgent(false)\n }\n }, [claimAgentId, claimAgentToken, claimDisplayName, claimingAgent, mutateAgents, token])\n\n const provisionLocalAgent = useCallback(async () => {\n if (!token || provisioningAgent) return\n setProvisioningAgent(true)\n setClaimAgentError('')\n setClaimAgentMessage('')\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n display_name: provisionDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(typeof data.detail === 'string' ? data.detail : '生成 Agent 凭证失败')\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) throw new Error('后端未返回 agent_id 或 agent_token')\n setProvisionedAgent({ agent_id: agentId, agent_token: agentToken })\n setClaimAgentMessage('Agent 凭证已生成')\n setProvisionDisplayName('')\n await mutateAgents()\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentId)\n setSelectedTopicId('')\n } catch (error) {\n setClaimAgentError(error instanceof Error ? error.message : '生成 Agent 凭证失败')\n } finally {\n setProvisioningAgent(false)\n }\n }, [mutateAgents, provisionDisplayName, provisioningAgent, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = file.type.startsWith('image/')\n const isAudio = file.type.startsWith('audio/')\n const isVideo = file.type.startsWith('video/')\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: asset.url, filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {label}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => setSettingsOpen(true)}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const label = senderLabel(message)\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {selectedTopicRunStatus.agentName}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n // eslint-disable-next-line @next/next/no-img-element\n \"\"\n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder=\"搜索 Agent / Topic / 群聊\"\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n
\n
\n \n 主机目录\n {agents.length}\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n return (\n
\n
\n {group.host}\n {online}/{group.rows.length}\n
\n
\n {group.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n }}\n className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${active ? 'bg-[#0d0d0d] text-white' : 'bg-white text-slate-700'}`}\n >\n \n \n {compactAgentName(agent)}\n {runtimeLine(runtime) && {runtimeLine(runtime)}}\n \n \n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n
\n

Agent 绑定

\n
\n

绑定已有 Agent

\n
\n setClaimAgentId(event.target.value)}\n placeholder=\"已有 agent_id\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n setClaimAgentToken(event.target.value)}\n placeholder=\"已有 agent_token\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n setClaimDisplayName(event.target.value)}\n placeholder=\"显示名称(可选)\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n void claimExistingAgent()}\n disabled={!claimAgentId.trim() || !claimAgentToken.trim() || claimingAgent}\n className=\"w-full rounded-2xl bg-[#0d0d0d] px-4 py-3 text-sm font-semibold text-white disabled:bg-slate-300\"\n >\n {claimingAgent ? '绑定中...' : '绑定已有 Agent'}\n \n
\n
\n\n
\n

生成本地 Agent

\n
\n setProvisionDisplayName(event.target.value)}\n placeholder=\"新 Agent 名称(可选)\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n void provisionLocalAgent()}\n disabled={provisioningAgent}\n className=\"w-full rounded-2xl bg-[#0d0d0d] px-4 py-3 text-sm font-semibold text-white disabled:bg-slate-300\"\n >\n {provisioningAgent ? '生成中...' : '生成本地 Agent 凭证'}\n \n
\n
\n\n {provisionedAgent && (\n
\n

凭证只展示在这里,请在本地主机执行:

\n \n
\n )}\n\n {claimAgentMessage &&

{claimAgentMessage}

}\n {claimAgentError &&

{claimAgentError}

}\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n
\n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}, "2653c761632fe71aeab9f8ca8c14133aeafeb184": {"t": 1785773622.1251903, "n": 83965, "h": "f4b70e3cf21767ad", "c": "'use client'\n\nimport { signOut, useSession } from 'next-auth/react'\nimport { useRouter } from 'next/navigation'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport useSWR from 'swr'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { Bot, Camera, ChevronDown, ClipboardList, Clock3, FolderTree, Hash, Loader2, LocateFixed, Lock, LogOut, MessageSquare, Paperclip, Radio, Search, Send, Settings, SquarePen, Users, WifiOff, X } from 'lucide-react'\nimport { CLIENT_WTT_API_BASE, WS_BASE_URL } from '@/lib/api/base-url'\nimport { useWebSocket, type WsMessage } from '@/lib/useWebSocket'\n\nconst STATUS_STALE_MS = 15 * 60 * 1000\nconst STATUS_MAX_LINES = 10\nconst COMPLETE_HOLD_MS = 4500\nconst MAX_UPLOAD_BYTES = 100 * 1024 * 1024\n\ntype AgentRecord = {\n agent_id: string\n display_name?: string\n name?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n binding_method?: string\n bound_via?: string\n}\n\ntype TopicRecord = {\n id?: string\n topic_id?: string\n name?: string\n description?: string\n type?: string\n topic_type?: string\n task_id?: string\n task_type?: string\n unread_count?: number\n last_activity_at?: string\n last_message_at?: string\n created_at?: string\n member_agent_ids?: string[]\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'group' | 'subscriber'\n\ntype TopicMember = {\n agent_id: string\n display_name?: string\n role?: string\n}\n\ntype ChatMessage = {\n message_id: string\n topic_id?: string\n sender_id: string\n sender_display_name?: string\n sender_type: 'human' | 'agent'\n content: string\n timestamp: string\n}\n\ntype RuntimeInfo = {\n hostname?: string\n host_agent_id?: string\n provider?: string\n adapter?: string\n current_model?: string\n model?: string\n}\n\ntype TypingState = {\n agentId: string\n agentName?: string\n adapter?: string\n model?: string\n statusText?: string\n statusKind?: string\n statusLines: Array<{ id: string; text: string; kind?: string; ts: number }>\n startedAt: number\n expiresAt: number\n}\n\ntype BillingMe = {\n entitlement?: {\n plan?: string\n status?: string\n ends_at?: string | null\n limits?: {\n window_limit?: number\n monthly_limit?: number\n }\n }\n cloud_agent_usage?: {\n window_count?: number\n monthly_count?: number\n blocked_until?: string | null\n }\n}\n\ntype PendingAsset = {\n url: string\n filename: string\n kind: 'image' | 'audio' | 'video' | 'file'\n token: string\n}\n\ntype FailedSend = {\n content: string\n draft: string\n assets: PendingAsset[]\n agentId: string\n topicId: string\n taskId: string\n error: string\n}\n\ntype ProvisionedAgent = {\n agent_id: string\n agent_token: string\n}\n\nfunction authHeaders(token?: string): HeadersInit {\n return token ? { Authorization: `Bearer ${token}` } : {}\n}\n\nfunction topicId(topic?: TopicRecord | null): string {\n return String(topic?.topic_id || topic?.id || '').trim()\n}\n\nfunction topicTime(topic: TopicRecord): string {\n return String(topic.last_activity_at || topic.last_message_at || topic.created_at || '')\n}\n\nfunction displayName(agent?: AgentRecord | null): string {\n return String(agent?.display_name || agent?.name || agent?.agent_id || 'Agent')\n}\n\nfunction agentInitial(name: string): string {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction senderLabel(message: ChatMessage): string {\n return String(message.sender_display_name || message.sender_id || (message.sender_type === 'agent' ? 'Agent' : 'You'))\n}\n\nfunction topicKind(topic?: TopicRecord | null): string {\n return String(topic?.topic_type || topic?.type || 'discussion').toLowerCase()\n}\n\nfunction isGroupTopic(topic?: TopicRecord | null): boolean {\n return ['discussion', 'collaborative'].includes(topicKind(topic))\n}\n\nfunction topicGroup(topic: TopicRecord): TopicGroupKey {\n if (topicKind(topic) === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topicKind(topic) === 'broadcast') return 'subscriber'\n return 'group'\n}\n\nfunction topicGroupMeta(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return { label: 'P2P 私聊', Icon: Lock, tone: 'bg-indigo-50 text-indigo-700 border-indigo-200' }\n case 'task':\n return { label: '任务 Topic', Icon: ClipboardList, tone: 'bg-emerald-50 text-emerald-700 border-emerald-200' }\n case 'group':\n return { label: '群聊 / 讨论', Icon: Users, tone: 'bg-sky-50 text-sky-700 border-sky-200' }\n case 'subscriber':\n return { label: '订阅 / 广播', Icon: Radio, tone: 'bg-amber-50 text-amber-700 border-amber-200' }\n }\n}\n\nfunction topicKindLabel(topic?: TopicRecord | null): string {\n if (!topic) return 'Topic'\n if (topic.task_id) return '任务'\n switch (topicKind(topic)) {\n case 'p2p':\n return 'P2P'\n case 'broadcast':\n return '订阅'\n case 'collaborative':\n return '协作群聊'\n default:\n return '群聊'\n }\n}\n\nfunction compactId(value: string, prefixLength = 10, suffixLength = 4): string {\n const text = String(value || '').trim()\n if (text.length <= prefixLength + suffixLength + 1) return text\n return `${text.slice(0, prefixLength)}…${text.slice(-suffixLength)}`\n}\n\nfunction compactAgentName(agent?: AgentRecord | null): string {\n const name = displayName(agent)\n if (/^agent-[a-f0-9]{10,}$/i.test(name)) return compactId(name, 9, 4)\n return name\n}\n\nfunction compactTopicTitle(topic?: TopicRecord | null): string {\n const name = String(topic?.name || '').trim()\n if (!name) return '选择 Topic'\n const taskMatch = /^TASK-[a-f0-9]{8}\\s+(.+)$/i.exec(name)\n if (taskMatch?.[1]) return taskMatch[1].trim()\n if (/^TASK-[a-f0-9]{8}$/i.test(name)) return 'New Task'\n return name\n}\n\nfunction isDefaultTaskTitle(topic?: TopicRecord | null): boolean {\n const name = String(topic?.name || '').trim()\n if (!topic?.task_id) return false\n return !name || name === 'New Task' || /^TASK-[a-f0-9]{8}\\s+New Task$/i.test(name)\n}\n\nfunction titleFromFirstMessage(content: string): string {\n const line = String(content || '')\n .split('\\n')\n .map((item) => item.replace(/\\[[^\\]]+\\]\\([^)]+\\)/g, '').trim())\n .find(Boolean)\n if (!line) return 'New Task'\n return line.length > 36 ? `${line.slice(0, 34)}...` : line\n}\n\nfunction topicIcon(topic?: TopicRecord | null) {\n if (!topic) return Hash\n if (topic.task_id) return ClipboardList\n switch (topicKind(topic)) {\n case 'p2p':\n return Lock\n case 'broadcast':\n return Radio\n case 'collaborative':\n return Users\n default:\n return Hash\n }\n}\n\nfunction stripMobileMetaBlocks(content: string): string {\n let cleaned = content.replace(\n /┌─\\s*(.+?)\\s*─+\\n((?:│[^\\n]*\\n?)*)└─+\\n?/g,\n '',\n )\n cleaned = cleaned.replace(/\\[(Switched\\s*→\\s*)?Model:\\s*[^\\]]*\\]\\s*/g, '')\n cleaned = cleaned.replace(/\\[Agent Role Template\\][\\s\\S]*?\\[\\/Agent Role Template\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Agent Soul\\][\\s\\S]*?\\[\\/WTT Agent Soul\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Persona\\][\\s\\S]*?\\[\\/WTT Worker Persona\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[WTT Worker Context\\][\\s\\S]*?\\[\\/WTT Worker Context\\]\\s*/gi, '')\n cleaned = cleaned.replace(/\\[FILE_CONTENT\\b[^\\]]*\\][\\s\\S]*?\\[\\/FILE_CONTENT\\]\\s*/g, '')\n return cleaned.trim()\n}\n\nconst MOBILE_PROGRESS_PATTERNS = [\n /^Time:\\s*\\d{1,2}:\\d{2}:\\d{2}\\s*\\n\\s*Progress:\\s*\\d+%/m,\n /^Status:\\s*\\[Task:/m,\n /^\\[STATUS\\]\\s*(Started|Completed)/m,\n /^Plan Mode result:/m,\n /^Plan Mode结果/m,\n /^Progress:\\s*\\d+%\\s*$/m,\n /^\\[TASK_STATUS\\]/m,\n /^\\[TASK_RUN\\]/m,\n /^\\[[^\\]]+\\]\\s*状态=.*\\|\\s*动作=.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b.*心跳=\\d+s/m,\n /^\\[[^\\]]+\\]\\s*\\|\\s*状态\\s*=\\s*doing\\b/m,\n /^🤔\\s*Agent thinking/m,\n]\n\nfunction isMobileProgressMessage(content: string): boolean {\n const cleaned = stripMobileMetaBlocks(content).trim()\n if (!cleaned) return true\n return MOBILE_PROGRESS_PATTERNS.some((pattern) => pattern.test(cleaned))\n}\n\nfunction isMobileStatusRecord(record: Record): boolean {\n const semantic = String(record.semantic_type || record.message_type || record.kind || '').trim()\n return ['task_request', 'TASK_REQUEST', 'task_status', 'TASK_STATUS', 'system', 'SYSTEM', 'notification', 'NOTIFICATION'].includes(semantic)\n}\n\nfunction adapterDisplayName(adapterRaw?: unknown): string {\n const adapter = String(adapterRaw || '').toLowerCase()\n if (adapter.includes('codex')) return 'Codex'\n if (adapter.includes('claude')) return 'Claude'\n if (adapter.includes('gemini')) return 'Gemini'\n if (adapter.includes('deepseek')) return 'DeepSeek'\n return 'Agent'\n}\n\nfunction statusFromProgressMessage(contentRaw: unknown, adapterRaw?: unknown): { text: string; kind: string } | null {\n const content = stripMobileMetaBlocks(String(contentRaw || '')).trim()\n if (!content.startsWith('[TASK_STATUS]')) return null\n const action = content.match(/\\baction=([^\\n\\r]+)/)?.[1]?.trim() || ''\n const status = content.match(/\\bstatus=([^\\s\\n\\r]+)/)?.[1]?.trim() || ''\n if (!action && !status) return null\n\n const [group, detail = ''] = action.split(/:(.+)/)\n const kind = group || status || 'running'\n const actor = adapterDisplayName(adapterRaw)\n if (group === 'session') {\n if (detail.includes('thread.started') || detail.includes('turn.started')) return { text: `${actor} 会话已启动`, kind: 'session' }\n if (detail.includes('completed')) return { text: `${actor} 会话已完成`, kind: 'session' }\n return { text: `${actor} 会话状态:${detail || status}`, kind: 'session' }\n }\n if (group === 'response') {\n const output = detail.trim()\n return { text: output ? `${actor} 输出:${output.slice(0, 80)}` : `${actor} 正在输出`, kind: 'response' }\n }\n if (group === 'command') return { text: `${actor} 执行命令:${detail || status}`, kind: 'command' }\n if (group === 'tool') return { text: `${actor} 调用工具:${detail || status}`, kind: 'tool' }\n return { text: `Agent 状态:${action || status}`, kind }\n}\n\nfunction mobileStatusKindLabel(kind?: string): string {\n const normalized = String(kind || '').toLowerCase()\n if (normalized.includes('queued')) return '排队'\n if (normalized.includes('command')) return '命令'\n if (normalized.includes('tool')) return '工具'\n if (normalized.includes('response')) return '输出'\n if (normalized.includes('session')) return '会话'\n if (normalized.includes('complete') || normalized.includes('done')) return '完成'\n if (normalized.includes('error') || normalized.includes('fail')) return '异常'\n return '运行'\n}\n\nfunction filenameFromUrl(url: string): string {\n const clean = decodeURIComponent(String(url || '').split('?')[0].split('#')[0])\n return clean.split('/').pop() || 'file'\n}\n\nfunction mobileFileMeta(label: string, href: string): { isAttachment: boolean; kind: string; name: string } {\n const cleanLabel = String(label || '').replace(/^(file|audio|video):/i, '').trim()\n const name = cleanLabel || filenameFromUrl(href)\n const candidate = `${name} ${href}`.toLowerCase()\n const isAudio = /(^|\\.)((mp3|wav|ogg|m4a|aac|flac))(\\?|#|\\s|$)/i.test(candidate)\n const isVideo = /(^|\\.)((mp4|webm|mov|m4v))(\\?|#|\\s|$)/i.test(candidate)\n const isFile = /(^|\\.)((pdf|doc|docx|ppt|pptx|xls|xlsx|csv|zip|tar|gz|md|txt|html|htm))(\\?|#|\\s|$)/i.test(candidate)\n if (/^audio:/i.test(String(label)) || isAudio) return { isAttachment: true, kind: 'AUDIO', name }\n if (/^video:/i.test(String(label)) || isVideo) return { isAttachment: true, kind: 'VIDEO', name }\n if (/^file:/i.test(String(label)) || isFile || href.includes('/media/')) return { isAttachment: true, kind: 'FILE', name }\n return { isAttachment: false, kind: '', name }\n}\n\nfunction childText(children: React.ReactNode): string {\n if (typeof children === 'string' || typeof children === 'number') return String(children)\n if (Array.isArray(children)) return children.map(childText).join('')\n return ''\n}\n\nfunction MobileMarkdownLink({ href, children }: { href?: string; children?: React.ReactNode }) {\n const url = String(href || '')\n const label = childText(children)\n const meta = mobileFileMeta(label, url)\n if (meta.isAttachment) {\n return (\n \n {meta.kind}\n {meta.name}\n \n )\n }\n return (\n \n {children}\n \n )\n}\n\nfunction MobileMarkdownImage({ src, alt }: { src?: string; alt?: string }) {\n const url = String(src || '')\n return (\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n {alt\n {alt && {alt}}\n \n )\n}\n\nfunction runtimeHostLabel(agent: AgentRecord, runtime?: RuntimeInfo): string {\n return String(runtime?.hostname || runtime?.host_agent_id || agent.cloud_host_agent_id || '未上报主机').trim()\n}\n\nfunction runtimeLine(runtime?: RuntimeInfo): string {\n if (!runtime) return ''\n return [runtime.adapter || runtime.provider, runtime.current_model || runtime.model].filter(Boolean).join(' · ')\n}\n\nfunction agentSearchText(agent: AgentRecord, runtime?: RuntimeInfo, host = ''): string {\n return [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n runtimeLine(runtime),\n ].join('\\n').toLowerCase()\n}\n\nfunction agentSearchRank(agent: AgentRecord, query: string, host = ''): number {\n const fields = [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n host,\n ].map((field) => field.toLowerCase()).filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n}\n\nfunction humanSender(session: unknown): string {\n const s = session as { userId?: string; user?: { name?: string | null; email?: string | null } } | null | undefined\n const uid = s?.userId || ''\n return s?.user?.name || s?.user?.email || (uid ? `user_${uid.slice(0, 8)}` : 'user_default')\n}\n\nfunction normalizeMessages(raw: unknown): ChatMessage[] {\n const rows = Array.isArray(raw) ? raw : []\n return rows.flatMap((item) => {\n const rec = item as Record\n const content = String(rec.content || '')\n if (isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return []\n const senderType = String(rec.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human'\n return [{\n message_id: String(rec.message_id || rec.id || `${rec.created_at || Date.now()}-${Math.random()}`),\n topic_id: String(rec.topic_id || ''),\n sender_id: String(rec.sender_id || ''),\n sender_display_name: rec.sender_display_name ? String(rec.sender_display_name) : undefined,\n sender_type: senderType,\n content,\n timestamp: String(rec.timestamp || rec.created_at || new Date().toISOString()),\n }]\n })\n}\n\nfunction normalizeWsMessage(raw: unknown): ChatMessage | null {\n const rec = raw as Record\n const msg = (rec.message && typeof rec.message === 'object' ? rec.message : rec) as Record\n const id = String(msg.message_id || msg.id || '')\n const content = String(msg.content || '')\n if (!id || !content) return null\n if (isMobileStatusRecord(msg) || isMobileStatusRecord(rec) || isMobileProgressMessage(content)) return null\n return {\n message_id: id,\n topic_id: String(msg.topic_id || ''),\n sender_id: String(msg.sender_id || ''),\n sender_display_name: msg.sender_display_name ? String(msg.sender_display_name) : undefined,\n sender_type: String(msg.sender_type || '').toLowerCase() === 'agent' ? 'agent' : 'human',\n content,\n timestamp: String(msg.timestamp || msg.created_at || new Date().toISOString()),\n }\n}\n\nfunction shouldCountUnreadMessage(message: ChatMessage): boolean {\n const content = stripMobileMetaBlocks(message.content).trim()\n return Boolean(content && !isMobileProgressMessage(content))\n}\n\nfunction collectNestedRecords(value: unknown, out: Record[] = [], depth = 0): Record[] {\n if (!value || typeof value !== 'object' || depth > 3) return out\n const record = value as Record\n out.push(record)\n for (const key of ['payload', 'data', 'event', 'item', 'message', 'delta', 'metadata', 'detail']) {\n collectNestedRecords(record[key], out, depth + 1)\n }\n return out\n}\n\nfunction eventString(record: Record, keys: string[]): string {\n const records = collectNestedRecords(record)\n for (const key of keys) {\n for (const source of records) {\n const value = source[key]\n if (value == null) continue\n if (typeof value === 'string' && value.trim()) return value.trim()\n if (typeof value === 'number' || typeof value === 'boolean') return String(value)\n }\n }\n return ''\n}\n\nfunction statusTextFromTypingEvent(record: Record): string | undefined {\n const direct = eventString(record, ['status_text', 'statusText', 'activity_text', 'activityText', 'message', 'detail', 'text', 'summary', 'description', 'progress'])\n if (direct) return direct\n const command = eventString(record, ['command', 'cmd', 'shell_command'])\n if (command) return `执行命令:${command}`\n const tool = eventString(record, ['tool', 'tool_name', 'toolName', 'name'])\n if (tool) return `调用工具:${tool}`\n const phase = eventString(record, ['phase', 'stage', 'step', 'status'])\n if (phase) return `阶段:${phase}`\n return undefined\n}\n\nfunction statusKindFromTypingEvent(record: Record): string | undefined {\n return eventString(record, ['status_kind', 'statusKind', 'kind', 'event_kind', 'eventKind', 'phase', 'type', 'status']) || undefined\n}\n\nfunction appendTypingStatus(existing: TypingState | undefined, update: Partial & { ttlMs?: number }, now: number): TypingState {\n const text = String(update.statusText || '').trim()\n const kind = update.statusKind\n const lines = existing?.statusLines ? [...existing.statusLines] : []\n if (text) {\n const last = lines[lines.length - 1]\n if (last && last.text === text && last.kind === kind) lines[lines.length - 1] = { ...last, ts: now }\n else lines.push({ id: `${now}-${lines.length}-${kind || 'status'}`, text, kind, ts: now })\n }\n return {\n agentId: update.agentId || existing?.agentId || '',\n agentName: update.agentName || existing?.agentName,\n adapter: update.adapter || existing?.adapter,\n model: update.model || existing?.model,\n statusText: text || existing?.statusText,\n statusKind: kind || existing?.statusKind,\n statusLines: lines.slice(-STATUS_MAX_LINES),\n startedAt: existing?.startedAt || now,\n expiresAt: now + (update.ttlMs || STATUS_STALE_MS),\n }\n}\n\nfunction shortTime(raw: string): string {\n if (!raw) return ''\n const d = new Date(raw)\n if (Number.isNaN(d.getTime())) return ''\n return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n}\n\nfunction quotaText(billing?: BillingMe | null): string {\n const usage = billing?.cloud_agent_usage\n const limits = billing?.entitlement?.limits\n return `Cloud Agent ${usage?.monthly_count || 0}/${limits?.monthly_limit || 500} 月额度 · 连续 ${usage?.window_count || 0}/${limits?.window_limit || 30}`\n}\n\nfunction isBrowserOnline(): boolean {\n if (typeof navigator === 'undefined') return true\n return navigator.onLine !== false\n}\n\nexport default function MobileFeedPage() {\n const router = useRouter()\n const { data: session, status } = useSession()\n const token = session?.accessToken as string | undefined\n const [selectedAgentId, setSelectedAgentId] = useState('')\n const [selectedTopicId, setSelectedTopicId] = useState('')\n const [draft, setDraft] = useState('')\n const [sending, setSending] = useState(false)\n const [uploading, setUploading] = useState(false)\n const [uploadProgress, setUploadProgress] = useState(null)\n const [selectorOpen, setSelectorOpen] = useState(false)\n const [settingsOpen, setSettingsOpen] = useState(false)\n const [attachOpen, setAttachOpen] = useState(false)\n const [search, setSearch] = useState('')\n const [pendingAssets, setPendingAssets] = useState([])\n const [typingByTopic, setTypingByTopic] = useState>({})\n const [creatingTask, setCreatingTask] = useState(false)\n const [failedSend, setFailedSend] = useState(null)\n const [browserOnline, setBrowserOnline] = useState(true)\n const [claimAgentId, setClaimAgentId] = useState('')\n const [claimAgentToken, setClaimAgentToken] = useState('')\n const [claimDisplayName, setClaimDisplayName] = useState('')\n const [claimingAgent, setClaimingAgent] = useState(false)\n const [claimAgentMessage, setClaimAgentMessage] = useState('')\n const [claimAgentError, setClaimAgentError] = useState('')\n const [provisionDisplayName, setProvisionDisplayName] = useState('')\n const [provisioningAgent, setProvisioningAgent] = useState(false)\n const [provisionedAgent, setProvisionedAgent] = useState(null)\n const [isAndroidWebView, setIsAndroidWebView] = useState(false)\n const scrollRef = useRef(null)\n const composerRef = useRef(null)\n const pendingCreatedTopicIdRef = useRef('')\n const pendingRenameTaskRef = useRef<{ taskId: string; topicId: string } | null>(null)\n const deepLinkAgentAppliedRef = useRef('')\n const deepLinkTopicAppliedRef = useRef('')\n const fileInputRef = useRef(null)\n const cameraInputRef = useRef(null)\n const sheetHistoryRef = useRef(false)\n const lastReadSyncRef = useRef<{ topicId: string; ts: number } | null>(null)\n const searchInteractiveRef = useRef(false)\n\n const openFilePicker = useCallback((input: HTMLInputElement | null) => {\n if (!input) return\n input.click()\n setAttachOpen(false)\n }, [])\n\n useEffect(() => {\n if (status === 'unauthenticated') {\n const source = typeof window !== 'undefined'\n ? String(new URLSearchParams(window.location.search).get('source') || '').toLowerCase()\n : ''\n router.replace(source === 'android'\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed')\n }\n }, [router, status])\n\n useEffect(() => {\n const params = new URLSearchParams(window.location.search)\n setIsAndroidWebView(String(params.get('source') || '').toLowerCase() === 'android')\n }, [])\n\n useEffect(() => {\n const updateOnline = () => setBrowserOnline(isBrowserOnline())\n updateOnline()\n window.addEventListener('online', updateOnline)\n window.addEventListener('offline', updateOnline)\n return () => {\n window.removeEventListener('online', updateOnline)\n window.removeEventListener('offline', updateOnline)\n }\n }, [])\n\n useEffect(() => {\n const anySheetOpen = selectorOpen || settingsOpen\n if (anySheetOpen && !sheetHistoryRef.current) {\n window.history.pushState({ wttMobileSheet: true }, '')\n sheetHistoryRef.current = true\n }\n if (!selectorOpen) searchInteractiveRef.current = false\n }, [selectorOpen, settingsOpen])\n\n useEffect(() => {\n const onPopState = () => {\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n setSelectorOpen(false)\n setSettingsOpen(false)\n setAttachOpen(false)\n }\n }\n window.addEventListener('popstate', onPopState)\n return () => window.removeEventListener('popstate', onPopState)\n }, [])\n\n const closeSheet = useCallback((sheet: 'selector' | 'settings') => {\n if (sheet === 'selector') setSelectorOpen(false)\n if (sheet === 'settings') setSettingsOpen(false)\n setAttachOpen(false)\n if (sheetHistoryRef.current) {\n sheetHistoryRef.current = false\n window.history.back()\n }\n }, [])\n\n const { data: agentsRaw, mutate: mutateAgents } = useSWR(\n token ? ['mobile-agents', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/my`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 15000, revalidateOnFocus: true },\n )\n\n const agents = useMemo(() => Array.isArray(agentsRaw) ? agentsRaw : [], [agentsRaw])\n\n const { data: statsRaw } = useSWR(\n token ? ['mobile-agent-stats', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/stats`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json()\n },\n { refreshInterval: 10000, revalidateOnFocus: true },\n )\n\n const runtimeMap = useMemo(() => (((statsRaw as Record | null)?.runtimes || {}) as Record), [statsRaw])\n const onlineAgents = useMemo(() => new Set(((statsRaw as Record | null)?.online_agents as string[] | undefined) || []), [statsRaw])\n\n useEffect(() => {\n if (!selectedAgentId && agents.length) setSelectedAgentId(agents[0].agent_id)\n if (selectedAgentId && agents.length && !agents.some((a) => a.agent_id === selectedAgentId)) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agents[0].agent_id)\n setSelectedTopicId('')\n }\n }, [agents, selectedAgentId])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !agents.length) return\n const key = window.location.search\n if (!key || deepLinkAgentAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const agentFromUrl = String(params.get('agent_id') || params.get('agentId') || '').trim()\n if (!agentFromUrl) {\n deepLinkAgentAppliedRef.current = key\n return\n }\n if (agents.some((agent) => agent.agent_id === agentFromUrl)) {\n deepLinkAgentAppliedRef.current = key\n if (selectedAgentId !== agentFromUrl) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentFromUrl)\n setSelectedTopicId('')\n }\n }\n }, [agents, selectedAgentId])\n\n const selectedAgent = useMemo(() => agents.find((a) => a.agent_id === selectedAgentId) || null, [agents, selectedAgentId])\n\n const { data: topicsRaw, mutate: mutateTopics } = useSWR(\n token && selectedAgentId ? ['mobile-topics', token, selectedAgentId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/subscribed?agent_id=${encodeURIComponent(selectedAgentId)}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 12000, revalidateOnFocus: true },\n )\n\n const topics = useMemo(() => {\n const list = (Array.isArray(topicsRaw) ? topicsRaw : []).filter((t) => topicId(t))\n return [...list].sort((a, b) => new Date(topicTime(b)).getTime() - new Date(topicTime(a)).getTime())\n }, [topicsRaw])\n\n const updateTopicUnreadCache = useCallback((targetTopicId: string, updater: (topic: TopicRecord) => TopicRecord) => {\n if (!targetTopicId) return\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n let changed = false\n const next = (current as TopicRecord[]).map((topic) => {\n if (topicId(topic) !== targetTopicId) return topic\n const updated = updater(topic)\n if (updated !== topic) changed = true\n return updated\n })\n return changed ? next : current\n }, false)\n }, [mutateTopics])\n\n useEffect(() => {\n if (!selectedTopicId && topics.length) {\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (!selectedTopicId || !topics.length) return\n const topicExists = topics.some((t) => topicId(t) === selectedTopicId)\n if (!topicExists) {\n if (pendingCreatedTopicIdRef.current === selectedTopicId) return\n setSelectedTopicId(topicId(topics[0]))\n return\n }\n if (pendingCreatedTopicIdRef.current === selectedTopicId) {\n pendingCreatedTopicIdRef.current = ''\n }\n }, [selectedTopicId, topics])\n\n useEffect(() => {\n if (typeof window === 'undefined' || !topics.length) return\n const key = window.location.search\n if (!key || deepLinkTopicAppliedRef.current === key) return\n const params = new URLSearchParams(key)\n const topicFromUrl = String(params.get('topic_id') || params.get('topicId') || params.get('topic') || '').trim()\n const taskFromUrl = String(params.get('task_id') || params.get('taskId') || params.get('task') || '').trim()\n if (!topicFromUrl && !taskFromUrl) {\n deepLinkTopicAppliedRef.current = key\n return\n }\n const matched = topics.find((topic) => {\n const id = topicId(topic)\n const taskId = topic.task_id ? String(topic.task_id) : ''\n return (topicFromUrl && id === topicFromUrl) || (taskFromUrl && taskId === taskFromUrl)\n })\n if (!matched) return\n deepLinkTopicAppliedRef.current = key\n setSelectedTopicId(topicId(matched))\n }, [topics])\n\n const selectedTopic = useMemo(() => topics.find((t) => topicId(t) === selectedTopicId) || null, [selectedTopicId, topics])\n const selectedTaskId = selectedTopic?.task_id ? String(selectedTopic.task_id) : ''\n\n const { data: messagesRaw, mutate: mutateMessages } = useSWR(\n token && selectedAgentId && selectedTopicId ? ['mobile-messages', token, selectedAgentId, selectedTopicId] : null,\n async () => {\n const params = new URLSearchParams({ limit: '80', agent_id: selectedAgentId })\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/messages?${params.toString()}`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json()\n },\n { refreshInterval: 0, revalidateOnFocus: true },\n )\n\n const messages = useMemo(() => normalizeMessages(messagesRaw), [messagesRaw])\n\n useEffect(() => {\n if (!selectedTopicId || !Array.isArray(messagesRaw)) return\n const now = Date.now()\n setTypingByTopic((prev) => {\n let nextState = prev[selectedTopicId]\n let changed = false\n for (const item of messagesRaw) {\n const rec = item as Record\n const progress = statusFromProgressMessage(rec.content, nextState?.adapter)\n if (!progress) continue\n const rowTime = new Date(String(rec.timestamp || rec.created_at || '')).getTime()\n const ts = Number.isFinite(rowTime) ? rowTime : now\n if (ts + STATUS_STALE_MS < now) continue\n const senderId = String(rec.sender_id || selectedAgentId)\n nextState = appendTypingStatus(nextState, {\n agentId: senderId,\n agentName: rec.sender_display_name ? String(rec.sender_display_name) : displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progress.text,\n statusKind: progress.kind,\n adapter: nextState?.adapter,\n ttlMs: 60000,\n }, Math.max(now, ts))\n changed = true\n }\n if (!changed || !nextState) return prev\n return { ...prev, [selectedTopicId]: nextState }\n })\n }, [agents, messagesRaw, selectedAgentId, selectedTopicId])\n\n const { data: selectedTopicMembersRaw } = useSWR(\n token && selectedTopicId && isGroupTopic(selectedTopic) ? ['mobile-topic-members', token, selectedTopicId] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${selectedTopicId}/members`, {\n headers: authHeaders(token),\n cache: 'no-store',\n })\n if (!res.ok) return []\n return res.json() as Promise\n },\n { refreshInterval: 20000, revalidateOnFocus: true },\n )\n\n const selectedTopicMembers = useMemo(\n () => Array.isArray(selectedTopicMembersRaw) ? selectedTopicMembersRaw : [],\n [selectedTopicMembersRaw],\n )\n\n const selectedTopicMemberIdSet = useMemo(() => {\n const ids = new Set()\n for (const id of selectedTopic?.member_agent_ids || []) {\n if (id) ids.add(String(id))\n }\n for (const member of selectedTopicMembers) {\n if (member.agent_id) ids.add(member.agent_id)\n }\n return ids\n }, [selectedTopic?.member_agent_ids, selectedTopicMembers])\n\n const topicActorAgentId = useMemo(() => {\n if (!selectedTopic || selectedTopicMemberIdSet.size === 0) return selectedAgentId\n if (!isGroupTopic(selectedTopic) || selectedTaskId) return selectedAgentId\n if (selectedAgentId && selectedTopicMemberIdSet.has(selectedAgentId)) return selectedAgentId\n const ownedMember = agents.find((agent) => selectedTopicMemberIdSet.has(agent.agent_id))\n return ownedMember?.agent_id || selectedAgentId\n }, [agents, selectedAgentId, selectedTaskId, selectedTopic, selectedTopicMemberIdSet])\n\n const topicActorAgent = useMemo(\n () => agents.find((agent) => agent.agent_id === topicActorAgentId) || selectedAgent,\n [agents, selectedAgent, topicActorAgentId],\n )\n\n useEffect(() => {\n if (!selectedTopicId) return\n updateTopicUnreadCache(selectedTopicId, (topic) => {\n if (!Number(topic.unread_count || 0)) return topic\n return { ...topic, unread_count: 0 }\n })\n if (!Array.isArray(messagesRaw)) return\n\n const now = Date.now()\n const prev = lastReadSyncRef.current\n if (prev && prev.topicId === selectedTopicId && now - prev.ts < 5000) return\n lastReadSyncRef.current = { topicId: selectedTopicId, ts: now }\n void mutateTopics()\n }, [messagesRaw, mutateTopics, selectedTopicId, updateTopicUnreadCache])\n\n const { data: billing } = useSWR(\n token ? ['mobile-billing', token] : null,\n async () => {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/billing/me`, { headers: authHeaders(token), cache: 'no-store' })\n if (!res.ok) return null\n return res.json() as Promise\n },\n { refreshInterval: 5 * 60_000 },\n )\n\n const handleWsMessage = useCallback((msg: WsMessage) => {\n const rawEvent = msg as unknown as Record\n if (rawEvent.type === 'typing') {\n const tid = String(rawEvent.topic_id || '')\n if (!tid) return\n if (String(rawEvent.state || 'start').toLowerCase() === 'stop') return\n const aid = String(rawEvent.agent_id || selectedAgentId)\n const now = Date.now()\n const ttlRaw = Number(rawEvent.ttl_ms || 0)\n const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.max(ttlRaw, 30000) : undefined\n setTypingByTopic((prev) => ({\n ...prev,\n [tid]: appendTypingStatus(prev[tid], {\n agentId: aid,\n agentName: String(rawEvent.agent_display_name || '') || displayName(agents.find((a) => a.agent_id === aid)),\n adapter: String(rawEvent.adapter || '').trim() || undefined,\n model: String(rawEvent.model || rawEvent.model_id || rawEvent.current_model || '').trim() || undefined,\n statusText: statusTextFromTypingEvent(rawEvent),\n statusKind: statusKindFromTypingEvent(rawEvent),\n ttlMs,\n }, now),\n }))\n return\n }\n\n const incoming = normalizeWsMessage(rawEvent)\n if (!incoming) {\n const msgRecord = (rawEvent.message && typeof rawEvent.message === 'object' ? rawEvent.message : rawEvent) as Record\n const progressTopicId = String(msgRecord.topic_id || rawEvent.topic_id || selectedTopicId)\n if (progressTopicId) {\n const senderId = String(msgRecord.sender_id || rawEvent.agent_id || selectedAgentId)\n const senderDisplayName = msgRecord.sender_display_name ? String(msgRecord.sender_display_name) : undefined\n setTypingByTopic((prev) => {\n const progressStatus = statusFromProgressMessage(msgRecord.content, prev[progressTopicId]?.adapter)\n if (!progressStatus) return prev\n return {\n ...prev,\n [progressTopicId]: appendTypingStatus(prev[progressTopicId], {\n agentId: senderId,\n agentName: senderDisplayName || displayName(agents.find((agent) => agent.agent_id === senderId)),\n statusText: progressStatus.text,\n statusKind: progressStatus.kind,\n adapter: prev[progressTopicId]?.adapter,\n ttlMs: 60000,\n }, Date.now()),\n }\n })\n }\n return\n }\n const incomingTopicId = incoming.topic_id || ''\n const displayable = shouldCountUnreadMessage(incoming)\n const now = new Date().toISOString()\n if (incomingTopicId) {\n updateTopicUnreadCache(incomingTopicId, (topic) => {\n const currentUnread = Number(topic.unread_count || 0)\n if (incomingTopicId === selectedTopicId) {\n return { ...topic, last_activity_at: now, unread_count: 0 }\n }\n return {\n ...topic,\n last_activity_at: now,\n unread_count: displayable ? currentUnread + 1 : currentUnread,\n }\n })\n }\n if (incomingTopicId === selectedTopicId) {\n void mutateMessages((current: unknown) => {\n const list = normalizeMessages(current)\n if (list.some((m) => m.message_id === incoming.message_id)) return current\n return [...list, incoming]\n }, false)\n if (incoming.sender_type === 'agent') {\n setTypingByTopic((prev) => ({\n ...prev,\n [incoming.topic_id || selectedTopicId]: appendTypingStatus(prev[incoming.topic_id || selectedTopicId], {\n agentId: incoming.sender_id || selectedAgentId,\n statusText: 'Agent 已回复',\n statusKind: 'response',\n ttlMs: COMPLETE_HOLD_MS,\n }, Date.now()),\n }))\n }\n }\n }, [agents, mutateMessages, selectedAgentId, selectedTopicId, updateTopicUnreadCache])\n\n const wsUrl = selectedAgentId ? `${WS_BASE_URL}/ws/${selectedAgentId}?client=mobile-web` : ''\n const { state: wsState } = useWebSocket({ url: wsUrl, enabled: Boolean(token && selectedAgentId), token, onMessage: handleWsMessage })\n\n const selectedTopicRunStatus = useMemo(() => {\n if (!selectedTopicId) return null\n const typing = typingByTopic[selectedTopicId]\n if (!typing) return null\n const agentName = typing.agentName || displayName(agents.find((agent) => agent.agent_id === typing.agentId)) || 'Agent'\n const lines = typing.statusLines?.length\n ? typing.statusLines\n : typing.statusText\n ? [{ id: `${typing.startedAt}-status`, text: typing.statusText, kind: typing.statusKind, ts: typing.startedAt }]\n : []\n return {\n ...typing,\n agentName,\n statusText: typing.statusText || '等待 Agent 状态更新',\n lines,\n wsState,\n }\n }, [agents, selectedTopicId, typingByTopic, wsState])\n\n useEffect(() => {\n scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })\n }, [messages.length, selectedTopicId])\n\n useEffect(() => {\n const timer = window.setInterval(() => {\n const now = Date.now()\n setTypingByTopic((prev) => {\n let changed = false\n const next = { ...prev }\n for (const [key, value] of Object.entries(next)) {\n if (value.expiresAt < now) {\n delete next[key]\n changed = true\n }\n }\n return changed ? next : prev\n })\n }, 2500)\n return () => window.clearInterval(timer)\n }, [])\n\n const SelectedTopicIcon = topicIcon(selectedTopic)\n const selectedTopicMeta = selectedTopic\n ? [\n topicKindLabel(selectedTopic),\n isGroupTopic(selectedTopic) && selectedTopicMembers.length ? `${selectedTopicMembers.length} 成员` : '',\n ].filter(Boolean).join(' · ')\n : ''\n const mobileLoginCallback = isAndroidWebView\n ? '/mobile/login?callbackUrl=/mobile/feed&source=android'\n : '/mobile/login?callbackUrl=/mobile/feed'\n\n const groupedAgents = useMemo(() => {\n const q = search.trim().toLowerCase()\n const groups = new Map>()\n for (const agent of agents) {\n const runtime = runtimeMap[agent.agent_id]\n const host = runtimeHostLabel(agent, runtime)\n const haystack = agentSearchText(agent, runtime, host)\n if (q && !haystack.includes(q)) continue\n groups.set(host, [...(groups.get(host) || []), { agent, rank: q ? agentSearchRank(agent, q, host) : 2 }])\n }\n return Array.from(groups.entries())\n .map(([host, rows]) => ({\n host,\n rows: [...rows]\n .sort((a, b) => {\n if (a.rank !== b.rank) return a.rank - b.rank\n return displayName(a.agent).localeCompare(displayName(b.agent))\n })\n .map((row) => row.agent),\n }))\n .sort((a, b) => a.host.localeCompare(b.host))\n }, [agents, runtimeMap, search])\n\n const filteredTopics = useMemo(() => {\n const q = search.trim().toLowerCase()\n if (!q) return topics\n return topics.filter((t) => `${t.name || ''} ${topicId(t)} ${t.description || ''} ${topicKindLabel(t)} ${topicKind(t)}`.toLowerCase().includes(q))\n }, [search, topics])\n\n const groupedTopics = useMemo(() => {\n const groups: Record = {\n p2p: [],\n task: [],\n group: [],\n subscriber: [],\n }\n for (const topic of filteredTopics) groups[topicGroup(topic)].push(topic)\n return groups\n }, [filteredTopics])\n\n const sendMessage = useCallback(async (retry?: FailedSend) => {\n const sourceDraft = retry ? retry.draft : draft\n const sourceAssets = retry ? retry.assets : pendingAssets\n const sourceAgentId = retry ? retry.agentId : (topicActorAgentId || selectedAgentId)\n const sourceTopicId = retry ? retry.topicId : selectedTopicId\n const sourceTaskId = retry ? retry.taskId : selectedTaskId\n const sourceAgent = agents.find((agent) => agent.agent_id === sourceAgentId) || topicActorAgent\n const attachmentContent = sourceAssets.map((asset) => asset.token).join('\\n\\n')\n const content = retry?.content || [sourceDraft.trim(), attachmentContent].filter(Boolean).join('\\n\\n')\n if (!content || !token || !sourceAgentId || !sourceTopicId || sending) return\n if (!isBrowserOnline()) {\n setBrowserOnline(false)\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: '当前离线,消息已保留,恢复网络后可重试。',\n })\n return\n }\n setSending(true)\n setFailedSend(null)\n if (!retry) {\n setDraft('')\n setPendingAssets([])\n }\n const now = Date.now()\n setTypingByTopic((prev) => ({\n ...prev,\n [sourceTopicId]: appendTypingStatus(prev[sourceTopicId], {\n agentId: sourceAgentId,\n agentName: displayName(sourceAgent),\n statusText: '消息已发送,等待 Agent 接收',\n statusKind: 'queued',\n }, now),\n }))\n try {\n let res: Response\n if (sourceTaskId) {\n res = await fetch(`${CLIENT_WTT_API_BASE}/tasks/${sourceTaskId}/chat/send`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n sender_type: 'HUMAN',\n semantic_type: 'post',\n auto_run: true,\n }),\n })\n } else {\n res = await fetch(`${CLIENT_WTT_API_BASE}/topics/${sourceTopicId}/messages?agent_id=${encodeURIComponent(sourceAgentId)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n content,\n content_type: 'text',\n semantic_type: 'post',\n sender_type: 'HUMAN',\n sender_id: humanSender(session),\n }),\n })\n }\n if (!res.ok) {\n const data = await res.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : `发送失败 (${res.status})`\n throw new Error(detail)\n }\n if (sourceTaskId) {\n const optimisticTitle = titleFromFirstMessage(content)\n if (optimisticTitle !== 'New Task') {\n void mutateTopics((current?: TopicRecord[]) => {\n if (!Array.isArray(current)) return current\n return current.map((topic) => {\n if (topicId(topic) !== sourceTopicId || !isDefaultTaskTitle(topic)) return topic\n return {\n ...topic,\n name: optimisticTitle,\n last_activity_at: new Date().toISOString(),\n }\n })\n }, false)\n }\n if (pendingRenameTaskRef.current?.topicId === sourceTopicId) {\n pendingRenameTaskRef.current = null\n }\n window.setTimeout(() => void mutateTopics(), 1200)\n window.setTimeout(() => void mutateTopics(), 3500)\n }\n await mutateMessages()\n await mutateTopics()\n } catch (error) {\n const message = error instanceof Error ? error.message : '网络异常,发送失败'\n setFailedSend({\n content,\n draft: sourceDraft,\n assets: sourceAssets,\n agentId: sourceAgentId,\n topicId: sourceTopicId,\n taskId: sourceTaskId,\n error: message,\n })\n if (!draft.trim() && pendingAssets.length === 0) {\n setDraft(sourceDraft)\n setPendingAssets(sourceAssets)\n }\n } finally {\n setSending(false)\n }\n }, [agents, draft, mutateMessages, mutateTopics, pendingAssets, selectedAgentId, selectedTaskId, selectedTopicId, sending, session, token, topicActorAgent, topicActorAgentId])\n\n const createDefaultTask = useCallback(async () => {\n if (!token || !selectedAgentId || creatingTask) return\n setCreatingTask(true)\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/tasks`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n title: 'New Task',\n task_mode: 'single',\n priority: 'P1',\n status: 'todo',\n task_type: 'general',\n exec_mode: 'reasoning',\n owner_agent_id: selectedAgentId,\n runner_agent_id: selectedAgentId,\n created_by: humanSender(session),\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n alert(data.detail || '创建任务失败')\n return\n }\n const record = data as Record\n const nestedTopic = (record.topic && typeof record.topic === 'object' ? record.topic : {}) as Record\n const id = String(record.topic_id || nestedTopic.topic_id || nestedTopic.id || '').trim()\n const taskId = String(record.id || record.task_id || '').trim()\n if (id) {\n pendingCreatedTopicIdRef.current = id\n if (taskId) pendingRenameTaskRef.current = { taskId, topicId: id }\n const optimisticTopic: TopicRecord = {\n id,\n topic_id: id,\n name: String(nestedTopic.name || record.title || 'New Task'),\n description: String(nestedTopic.description || 'General task conversation'),\n type: 'discussion',\n topic_type: 'discussion',\n task_id: taskId,\n task_type: 'general',\n last_activity_at: new Date().toISOString(),\n }\n void mutateTopics((current: unknown) => {\n const list = (Array.isArray(current) ? current : []) as TopicRecord[]\n if (list.some((topic) => topicId(topic) === id)) return list\n return [optimisticTopic, ...list]\n }, false)\n setSelectedTopicId(id)\n window.setTimeout(() => {\n if (pendingCreatedTopicIdRef.current === id) pendingCreatedTopicIdRef.current = ''\n }, 8000)\n }\n void mutateTopics()\n setDraft('')\n } catch {\n alert('网络异常,请稍后重试')\n } finally {\n setCreatingTask(false)\n }\n }, [creatingTask, mutateTopics, selectedAgentId, session, token])\n\n const claimExistingAgent = useCallback(async () => {\n if (!token || claimingAgent) return\n const agentId = claimAgentId.trim()\n const agentToken = claimAgentToken.trim()\n if (!agentId || !agentToken) {\n setClaimAgentError('agent_id 和 agent_token 都不能为空')\n return\n }\n setClaimingAgent(true)\n setClaimAgentError('')\n setClaimAgentMessage('')\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/claim-existing`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n agent_id: agentId,\n agent_token: agentToken,\n display_name: claimDisplayName.trim() || undefined,\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(typeof data.detail === 'string' ? data.detail : '绑定 Agent 失败')\n }\n const newAgentId = String(data.agent_id || agentId).trim()\n setClaimAgentMessage('Agent 已绑定')\n setClaimAgentId('')\n setClaimAgentToken('')\n setClaimDisplayName('')\n await mutateAgents()\n if (newAgentId) {\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(newAgentId)\n setSelectedTopicId('')\n }\n } catch (error) {\n setClaimAgentError(error instanceof Error ? error.message : '绑定 Agent 失败')\n } finally {\n setClaimingAgent(false)\n }\n }, [claimAgentId, claimAgentToken, claimDisplayName, claimingAgent, mutateAgents, token])\n\n const provisionLocalAgent = useCallback(async () => {\n if (!token || provisioningAgent) return\n setProvisioningAgent(true)\n setClaimAgentError('')\n setClaimAgentMessage('')\n try {\n const res = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({\n display_name: provisionDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await res.json().catch(() => ({}))\n if (!res.ok) {\n throw new Error(typeof data.detail === 'string' ? data.detail : '生成 Agent 凭证失败')\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) throw new Error('后端未返回 agent_id 或 agent_token')\n setProvisionedAgent({ agent_id: agentId, agent_token: agentToken })\n setClaimAgentMessage('Agent 凭证已生成')\n setProvisionDisplayName('')\n await mutateAgents()\n pendingCreatedTopicIdRef.current = ''\n setSelectedAgentId(agentId)\n setSelectedTopicId('')\n } catch (error) {\n setClaimAgentError(error instanceof Error ? error.message : '生成 Agent 凭证失败')\n } finally {\n setProvisioningAgent(false)\n }\n }, [mutateAgents, provisionDisplayName, provisioningAgent, token])\n\n const uploadAsset = useCallback(async (file: File) => {\n if (!token) {\n alert('请先登录后再上传附件')\n return\n }\n if (file.size > MAX_UPLOAD_BYTES) {\n alert(`文件过大,最大 100MB,当前 ${(file.size / (1024 * 1024)).toFixed(1)}MB`)\n return\n }\n setUploading(true)\n setUploadProgress(0)\n try {\n const sign = await fetch(`${CLIENT_WTT_API_BASE}/media/sign`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ filename: file.name, mime_type: file.type || 'application/octet-stream', size: file.size }),\n })\n if (!sign.ok) throw new Error(await sign.text())\n const signed = await sign.json()\n await new Promise((resolve, reject) => {\n const xhr = new XMLHttpRequest()\n xhr.upload.addEventListener('progress', (event) => {\n if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 90))\n })\n xhr.addEventListener('load', () => {\n if (xhr.status >= 200 && xhr.status < 300) resolve()\n else reject(new Error(xhr.responseText || `Upload failed: ${xhr.status}`))\n })\n xhr.addEventListener('error', () => reject(new Error('Upload failed')))\n xhr.open('PUT', `${CLIENT_WTT_API_BASE}${signed.upload_url}`)\n xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')\n xhr.send(file)\n })\n setUploadProgress(95)\n const commit = await fetch(`${CLIENT_WTT_API_BASE}/media/commit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', ...authHeaders(token) },\n body: JSON.stringify({ upload_token: signed.upload_token }),\n })\n if (!commit.ok) throw new Error(await commit.text())\n const asset = await commit.json()\n const isImage = file.type.startsWith('image/')\n const isAudio = file.type.startsWith('audio/')\n const isVideo = file.type.startsWith('video/')\n const kind: PendingAsset['kind'] = isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file'\n const assetToken = isImage\n ? `![${file.name}](${asset.url})`\n : isAudio\n ? `[audio:${file.name}](${asset.url})`\n : isVideo\n ? `[video:${file.name}](${asset.url})`\n : `[file:${file.name}](${asset.url})`\n setPendingAssets((prev) => [...prev, { url: asset.url, filename: file.name, kind, token: assetToken }])\n setUploadProgress(100)\n } catch (error) {\n alert(error instanceof Error ? error.message : '上传失败')\n } finally {\n setUploading(false)\n setUploadProgress(null)\n }\n }, [token])\n\n const insertLocation = useCallback(() => {\n setAttachOpen(false)\n if (!navigator.geolocation) {\n alert('当前设备不支持定位')\n return\n }\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const { latitude, longitude } = pos.coords\n const token = `[location](https://maps.google.com/?q=${latitude},${longitude})`\n setDraft((prev) => `${prev}${prev ? '\\n\\n' : ''}${token}`)\n },\n (error) => alert(`定位失败:${error.message || 'permission denied'}`),\n { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 },\n )\n }, [])\n\n if (status === 'loading') {\n return
Loading WTT...
\n }\n\n return (\n
\n
\n
\n \n \n void createDefaultTask()}\n disabled={!selectedAgentId || creatingTask}\n className=\"rounded-xl p-2 text-slate-700 hover:bg-slate-100 disabled:text-slate-300\"\n aria-label=\"新建对话\"\n >\n \n \n \n
\n\n {!browserOnline && (\n
\n \n 当前网络离线,草稿和附件会保留,恢复后可继续发送。\n
\n )}\n\n {isGroupTopic(selectedTopic) && selectedTopicMembers.length > 0 && (\n
\n \n 群聊\n {selectedTopicMembers.slice(0, 8).map((member) => {\n const label = member.display_name || compactId(member.agent_id, 9, 4)\n return (\n \n {label}\n \n )\n })}\n {selectedTopicMembers.length > 8 && (\n +{selectedTopicMembers.length - 8}\n )}\n
\n )}\n\n
\n {!selectedAgentId ? (\n }\n onAction={() => setSettingsOpen(true)}\n />\n ) : !selectedTopicId ? (\n }\n actionDisabled={creatingTask}\n onAction={() => void createDefaultTask()}\n />\n ) : messages.length === 0 ? (\n \n ) : (\n messages.map((message) => {\n const isMine = message.sender_type === 'human'\n const label = senderLabel(message)\n const cleanContent = stripMobileMetaBlocks(message.content)\n return (\n
\n
\n
\n {agentInitial(label)}\n
\n
\n
\n {label}\n \n {isMine ? 'You' : 'AI'}\n \n {shortTime(message.timestamp)}\n
\n
\n
\n \n {cleanContent || message.content}\n \n
\n
\n
\n
\n
\n )\n })\n )}\n
\n\n
\n {selectedTopicRunStatus && (\n
\n
\n \n
\n
\n {selectedTopicRunStatus.agentName}\n \n {mobileStatusKindLabel(selectedTopicRunStatus.statusKind)}\n \n WS {selectedTopicRunStatus.wsState}\n
\n

{selectedTopicRunStatus.statusText}

\n
\n
\n {selectedTopicRunStatus.lines.length > 1 && (\n
\n {selectedTopicRunStatus.lines.slice(-2).map((line) => (\n
\n \n {mobileStatusKindLabel(line.kind)}\n {line.text}\n
\n ))}\n
\n )}\n
\n )}\n {failedSend && (\n
\n {failedSend.error || '发送失败'}\n \n \n
\n )}\n {pendingAssets.length > 0 && (\n
\n {pendingAssets.map((asset, index) => (\n
\n {asset.kind === 'image' ? (\n // eslint-disable-next-line @next/next/no-img-element\n \"\"\n ) : (\n FILE\n )}\n \n {asset.filename}\n {asset.kind}\n \n \n
\n ))}\n
\n )}\n {uploading && (\n
\n 正在上传 {uploadProgress ?? 0}%\n
\n )}\n
\n
\n \n {attachOpen && (\n
\n \n \n \n
\n )}\n
\n setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' && !event.shiftKey) {\n event.preventDefault()\n void sendMessage()\n }\n }}\n rows={1}\n placeholder={isGroupTopic(selectedTopic) ? '发送到群聊...' : selectedTopic?.task_id ? '给 Agent 发送任务...' : '发送消息...'}\n className=\"max-h-32 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-[15px] font-medium leading-6 outline-none placeholder:text-slate-400\"\n />\n void sendMessage()}\n disabled={(!draft.trim() && pendingAssets.length === 0) || sending || uploading || !selectedTopicId}\n className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0d] text-white disabled:bg-slate-300\"\n aria-label=\"发送消息\"\n >\n \n \n {\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n {\n const file = event.target.files?.[0]\n if (file) void uploadAsset(file)\n event.currentTarget.value = ''\n }}\n />\n
\n
\n
\n\n {selectorOpen && (\n closeSheet('selector')}>\n
\n
\n \n {\n searchInteractiveRef.current = true\n }}\n onFocus={(event) => {\n if (!searchInteractiveRef.current) event.currentTarget.blur()\n }}\n onChange={(e) => setSearch(e.target.value)}\n placeholder=\"搜索 Agent / Topic / 群聊\"\n className=\"min-w-0 flex-1 bg-transparent text-sm font-medium outline-none\"\n />\n
\n
\n
\n
\n
\n \n 主机目录\n {agents.length}\n
\n
\n {groupedAgents.length === 0 ? (\n
暂无 Agent,请先在完整 Web Feed 绑定或创建 Agent。
\n ) : groupedAgents.map((group) => {\n const online = group.rows.filter((a) => onlineAgents.has(a.agent_id)).length\n return (\n
\n
\n {group.host}\n {online}/{group.rows.length}\n
\n
\n {group.rows.map((agent) => {\n const runtime = runtimeMap[agent.agent_id]\n const active = agent.agent_id === selectedAgentId\n return (\n {\n setSelectedAgentId(agent.agent_id)\n setSelectedTopicId('')\n }}\n className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${active ? 'bg-[#0d0d0d] text-white' : 'bg-white text-slate-700'}`}\n >\n \n \n {compactAgentName(agent)}\n {runtimeLine(runtime) && {runtimeLine(runtime)}}\n \n \n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n \n Topics\n {filteredTopics.length}\n
\n
\n {(['p2p', 'task', 'group', 'subscriber'] as TopicGroupKey[]).map((groupKey) => {\n const items = groupedTopics[groupKey]\n const meta = topicGroupMeta(groupKey)\n const GroupIcon = meta.Icon\n if (items.length === 0 && search.trim()) return null\n return (\n
\n
\n \n \n {meta.label}\n \n {items.length}\n
\n
\n {items.length === 0 ? (\n
暂无{meta.label}
\n ) : items.map((topic) => {\n const id = topicId(topic)\n const TopicIcon = topicIcon(topic)\n return (\n {\n updateTopicUnreadCache(id, (row) => ({ ...row, unread_count: 0 }))\n setSelectedTopicId(id)\n closeSheet('selector')\n }}\n className={`w-full rounded-lg border px-3 py-2 text-left ${id === selectedTopicId ? 'border-slate-900 bg-slate-100' : 'border-slate-200 bg-slate-50'}`}\n >\n
\n \n {compactTopicTitle(topic) || compactId(id, 10, 4)}\n {!!topic.unread_count && {topic.unread_count}}\n
\n
\n {topic.description || topicKindLabel(topic)}\n
\n \n )\n })}\n
\n
\n )\n })}\n
\n
\n
\n
\n )}\n\n {settingsOpen && (\n closeSheet('settings')}>\n
\n
\n

Account

\n

{session?.user?.name || session?.user?.email || 'WTT User'}

\n

{billing?.entitlement?.plan === 'pro' ? 'Pro' : 'Free'} · {quotaText(billing)}

\n

网络 {browserOnline ? '在线' : '离线'} · WebSocket {wsState}

\n
\n
\n

Agent 绑定

\n
\n

绑定已有 Agent

\n
\n setClaimAgentId(event.target.value)}\n placeholder=\"已有 agent_id\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n setClaimAgentToken(event.target.value)}\n placeholder=\"已有 agent_token\"\n autoCapitalize=\"none\"\n autoCorrect=\"off\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n setClaimDisplayName(event.target.value)}\n placeholder=\"显示名称(可选)\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n void claimExistingAgent()}\n disabled={!claimAgentId.trim() || !claimAgentToken.trim() || claimingAgent}\n className=\"w-full rounded-2xl bg-[#0d0d0d] px-4 py-3 text-sm font-semibold text-white disabled:bg-slate-300\"\n >\n {claimingAgent ? '绑定中...' : '绑定已有 Agent'}\n \n
\n
\n\n
\n

生成本地 Agent

\n
\n setProvisionDisplayName(event.target.value)}\n placeholder=\"新 Agent 名称(可选)\"\n className=\"w-full rounded-2xl border border-slate-200 bg-white px-3 py-3 text-sm font-medium outline-none\"\n />\n void provisionLocalAgent()}\n disabled={provisioningAgent}\n className=\"w-full rounded-2xl bg-[#0d0d0d] px-4 py-3 text-sm font-semibold text-white disabled:bg-slate-300\"\n >\n {provisioningAgent ? '生成中...' : '生成本地 Agent 凭证'}\n \n
\n
\n\n {provisionedAgent && (\n
\n

凭证只展示在这里,请在本地主机执行:

\n \n
\n )}\n\n {claimAgentMessage &&

{claimAgentMessage}

}\n {claimAgentError &&

{claimAgentError}

}\n
\n 打开完整 Web Feed\n 移动端设置页\n \n
\n
\n )}\n\n
\n )\n}\n\nfunction EmptyCard({\n title,\n desc,\n actionLabel,\n actionIcon,\n actionDisabled,\n onAction,\n}: {\n title: string\n desc: string\n actionLabel?: string\n actionIcon?: React.ReactNode\n actionDisabled?: boolean\n onAction?: () => void\n}) {\n return (\n
\n
\n \n
\n

{title}

\n

{desc}

\n {actionLabel && onAction && (\n \n {actionIcon}\n {actionLabel}\n \n )}\n
\n )\n}\n\nfunction MobileSheet({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {\n return (\n
\n
\n
\n
\n

{title}

\n \n
\n
{children}
\n
\n
\n )\n}\n"}}, "components/ui/topic-column.tsx": {"da26680f49538a590680ceb96a49ed6ac0f5e222": {"t": 1785747475.7571406, "n": 157858, "h": "187a966f63e9dba0", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Search, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\nexport interface RecentTopicItem {\n topic_id: string\n topic_name?: string\n name?: string\n topic_type?: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n type?: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n last_activity_at?: string\n last_message_preview?: string\n last_sender_id?: string\n primary_agent_id?: string\n agent_ids?: string[]\n member_agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n unread_count?: number\n}\n\ninterface AgentOption {\n agent_id: string\n name?: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n typing_at?: number\n typing_topic_id?: string\n runtime_status?: string\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n recentTopics?: RecentTopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSubmitAgentOperation?: SubmitAgentOperation\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction newClientOperationId() {\n return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nexport type AgentOperationType =\n | 'cloud_agent_create'\n | 'cloud_sandbox_clone'\n | 'local_host_clone'\n | 'group_create'\n | 'team_create'\n\nexport type AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\ntype SubmitAgentOperation = (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n) => Promise\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n recentTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSubmitAgentOperation,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createActionOpen, setCreateActionOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [agentSearchQuery, setAgentSearchQuery] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: true,\n task: true,\n discuss: true,\n subscriber: true,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n const agentSearchResults = useMemo(() => {\n const query = agentSearchQuery.trim().toLowerCase()\n if (!query) return []\n const searchText = (agent: AgentOption) => [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n ].join('\\n').toLowerCase()\n const matchRank = (agent: AgentOption) => {\n const fields = [\n agent.agent_id.toLowerCase(),\n String(agent.name || '').toLowerCase(),\n String(agent.display_name || '').toLowerCase(),\n ].filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n }\n return agentOptions\n .filter((agent) => {\n return searchText(agent).includes(query)\n })\n .sort((a, b) => {\n const aRank = matchRank(a)\n const bRank = matchRank(b)\n if (aRank !== bRank) return aRank - bRank\n return a.display_name.localeCompare(b.display_name)\n })\n .slice(0, 8)\n }, [agentOptions, agentSearchQuery])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在提交群聊创建任务...' : 'Submitting group creation job...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n if (!onSubmitAgentOperation) {\n throw new Error(zh ? '后端异步 Action 未接入。' : 'Async operation action is unavailable.')\n }\n const job = await onSubmitAgentOperation(\n 'group_create',\n {\n name,\n description,\n creator_agent_id: creator,\n agent_ids: selectedIds,\n client_operation_id: newClientOperationId(),\n },\n undefined,\n (nextJob) => setGroupProgress(`${zh ? '正在创建群聊' : 'Creating group'} · ${nextJob.phase || nextJob.status || 'running'}`),\n )\n const result = (job.result || {}) as { topic?: TopicItem; topic_id?: string; member_agent_ids?: string[] }\n const topicId = String(result.topic_id || result.topic?.topic_id || '').trim()\n if (!topicId) throw new Error(zh ? '群聊创建完成但未返回 Topic ID。' : 'Group creation finished without topic id.')\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name: result.topic?.name || name,\n description: result.topic?.description || description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: result.topic?.creator_agent_id || creator,\n member_agent_ids: result.member_agent_ids || result.topic?.member_agent_ids || selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '正在提交团队创建任务...' : 'Submitting team creation job...')\n try {\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n if (!onSubmitAgentOperation) {\n throw new Error(zh ? '后端异步 Action 未接入。' : 'Async operation action is unavailable.')\n }\n const job = await onSubmitAgentOperation(\n 'team_create',\n {\n name,\n description,\n host_agent_id: teamHostId,\n adapter: teamAdapter,\n roles: roles.map((role) => ({\n id: role.id,\n label: role.label,\n display_name: role.shortLabel || role.label,\n role_template_id: role.id,\n role_template: serializeAgentRoleTemplate(role),\n })),\n client_operation_id: newClientOperationId(),\n },\n undefined,\n (nextJob) => setTeamProgress(`${zh ? '正在创建团队' : 'Creating team'} · ${nextJob.phase || nextJob.status || 'running'}`),\n )\n const result = (job.result || {}) as { topic?: TopicItem; topic_id?: string; agent_ids?: string[]; agents?: Array<{ agent_id?: string }> }\n const topicId = String(result.topic_id || result.topic?.topic_id || '').trim()\n const createdAgentIds = (Array.isArray(result.agent_ids) && result.agent_ids.length\n ? result.agent_ids\n : (result.agents || []).map((agent) => String(agent.agent_id || '').trim()).filter(Boolean))\n if (!topicId) throw new Error(zh ? '团队创建完成但未返回 Topic ID。' : 'Team creation finished without topic id.')\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name: result.topic?.name || name,\n description: result.topic?.description || description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: result.topic?.creator_agent_id || createdAgentIds[0],\n member_agent_ids: result.topic?.member_agent_ids || createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const visibleRecentTopics = useMemo(() => {\n const seen = new Set()\n return recentTopics\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .slice(0, 10)\n }, [recentTopics])\n\n const openRecentTopic = (topic: RecentTopicItem) => {\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n if (primaryAgentId && primaryAgentId !== selectedAgentId) onSelectAgent?.(primaryAgentId)\n onSelectTopic(topic.topic_id)\n }\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {createActionOpen && (\n
\n
\n
\n
\n

\n {zh ? '新建' : 'New'}\n

\n

\n {zh\n ? '选择你要创建的类型:云端 Agent、普通群聊,或带角色分工的团队。'\n : 'Choose what to create: a cloud agent, a group chat, or a role-based team.'}\n

\n
\n setCreateActionOpen(false)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n {onCreateCloudAgent && (\n {\n setCreateActionOpen(false)\n setCloudCreateError('')\n setCloudCreateOpen(true)\n }}\n disabled={cloudAgentBusy}\n className=\"group rounded-2xl border border-sky-200 bg-sky-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:bg-sky-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60 dark:border-sky-500/30 dark:bg-sky-500/10 dark:hover:bg-sky-500/15\"\n >\n \n \n \n \n {zh ? '新建 Agent' : 'New Agent'}\n \n \n {zh ? '创建 WTT 托管的云端 Agent,需要 Pro 权益。' : 'Create a WTT-hosted cloud agent. Requires Pro.'}\n \n \n )}\n\n {\n setCreateActionOpen(false)\n openGroupModal()\n }}\n className=\"group rounded-2xl border border-amber-200 bg-amber-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-100 hover:shadow-md dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15\"\n >\n \n \n \n \n {zh ? '新建群聊' : 'New Group'}\n \n \n {zh ? '选择已有 Agent,创建一个私有协作 Topic。' : 'Select existing agents and create a private collaboration topic.'}\n \n \n\n {\n setCreateActionOpen(false)\n openTeamModal()\n }}\n disabled={!onNewAgentFromHost || newAgentHosts.length === 0}\n className=\"group rounded-2xl border border-fuchsia-200 bg-fuchsia-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-fuchsia-300 hover:bg-fuchsia-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15\"\n >\n \n \n \n \n {zh ? '新建团队' : 'New Team'}\n \n \n {zh ? '选择团队模板,自动 clone 多个角色 Agent 并创建群聊。' : 'Choose a team template, clone role agents, and create a group chat.'}\n \n \n
\n
\n
\n )}\n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "ebb6168f811632b31fefe12bc9a26d0860792486": {"t": 1785773531.6273603, "n": 157858, "h": "187a966f63e9dba0", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Search, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\nexport interface RecentTopicItem {\n topic_id: string\n topic_name?: string\n name?: string\n topic_type?: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n type?: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n last_activity_at?: string\n last_message_preview?: string\n last_sender_id?: string\n primary_agent_id?: string\n agent_ids?: string[]\n member_agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n unread_count?: number\n}\n\ninterface AgentOption {\n agent_id: string\n name?: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n typing_at?: number\n typing_topic_id?: string\n runtime_status?: string\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n recentTopics?: RecentTopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSubmitAgentOperation?: SubmitAgentOperation\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction newClientOperationId() {\n return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nexport type AgentOperationType =\n | 'cloud_agent_create'\n | 'cloud_sandbox_clone'\n | 'local_host_clone'\n | 'group_create'\n | 'team_create'\n\nexport type AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\ntype SubmitAgentOperation = (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n) => Promise\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n recentTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSubmitAgentOperation,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createActionOpen, setCreateActionOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [agentSearchQuery, setAgentSearchQuery] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: true,\n task: true,\n discuss: true,\n subscriber: true,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n const agentSearchResults = useMemo(() => {\n const query = agentSearchQuery.trim().toLowerCase()\n if (!query) return []\n const searchText = (agent: AgentOption) => [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n ].join('\\n').toLowerCase()\n const matchRank = (agent: AgentOption) => {\n const fields = [\n agent.agent_id.toLowerCase(),\n String(agent.name || '').toLowerCase(),\n String(agent.display_name || '').toLowerCase(),\n ].filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n }\n return agentOptions\n .filter((agent) => {\n return searchText(agent).includes(query)\n })\n .sort((a, b) => {\n const aRank = matchRank(a)\n const bRank = matchRank(b)\n if (aRank !== bRank) return aRank - bRank\n return a.display_name.localeCompare(b.display_name)\n })\n .slice(0, 8)\n }, [agentOptions, agentSearchQuery])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在提交群聊创建任务...' : 'Submitting group creation job...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n if (!onSubmitAgentOperation) {\n throw new Error(zh ? '后端异步 Action 未接入。' : 'Async operation action is unavailable.')\n }\n const job = await onSubmitAgentOperation(\n 'group_create',\n {\n name,\n description,\n creator_agent_id: creator,\n agent_ids: selectedIds,\n client_operation_id: newClientOperationId(),\n },\n undefined,\n (nextJob) => setGroupProgress(`${zh ? '正在创建群聊' : 'Creating group'} · ${nextJob.phase || nextJob.status || 'running'}`),\n )\n const result = (job.result || {}) as { topic?: TopicItem; topic_id?: string; member_agent_ids?: string[] }\n const topicId = String(result.topic_id || result.topic?.topic_id || '').trim()\n if (!topicId) throw new Error(zh ? '群聊创建完成但未返回 Topic ID。' : 'Group creation finished without topic id.')\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name: result.topic?.name || name,\n description: result.topic?.description || description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: result.topic?.creator_agent_id || creator,\n member_agent_ids: result.member_agent_ids || result.topic?.member_agent_ids || selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '正在提交团队创建任务...' : 'Submitting team creation job...')\n try {\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n if (!onSubmitAgentOperation) {\n throw new Error(zh ? '后端异步 Action 未接入。' : 'Async operation action is unavailable.')\n }\n const job = await onSubmitAgentOperation(\n 'team_create',\n {\n name,\n description,\n host_agent_id: teamHostId,\n adapter: teamAdapter,\n roles: roles.map((role) => ({\n id: role.id,\n label: role.label,\n display_name: role.shortLabel || role.label,\n role_template_id: role.id,\n role_template: serializeAgentRoleTemplate(role),\n })),\n client_operation_id: newClientOperationId(),\n },\n undefined,\n (nextJob) => setTeamProgress(`${zh ? '正在创建团队' : 'Creating team'} · ${nextJob.phase || nextJob.status || 'running'}`),\n )\n const result = (job.result || {}) as { topic?: TopicItem; topic_id?: string; agent_ids?: string[]; agents?: Array<{ agent_id?: string }> }\n const topicId = String(result.topic_id || result.topic?.topic_id || '').trim()\n const createdAgentIds = (Array.isArray(result.agent_ids) && result.agent_ids.length\n ? result.agent_ids\n : (result.agents || []).map((agent) => String(agent.agent_id || '').trim()).filter(Boolean))\n if (!topicId) throw new Error(zh ? '团队创建完成但未返回 Topic ID。' : 'Team creation finished without topic id.')\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name: result.topic?.name || name,\n description: result.topic?.description || description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: result.topic?.creator_agent_id || createdAgentIds[0],\n member_agent_ids: result.topic?.member_agent_ids || createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const visibleRecentTopics = useMemo(() => {\n const seen = new Set()\n return recentTopics\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .slice(0, 10)\n }, [recentTopics])\n\n const openRecentTopic = (topic: RecentTopicItem) => {\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n if (primaryAgentId && primaryAgentId !== selectedAgentId) onSelectAgent?.(primaryAgentId)\n onSelectTopic(topic.topic_id)\n }\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {createActionOpen && (\n
\n
\n
\n
\n

\n {zh ? '新建' : 'New'}\n

\n

\n {zh\n ? '选择你要创建的类型:云端 Agent、普通群聊,或带角色分工的团队。'\n : 'Choose what to create: a cloud agent, a group chat, or a role-based team.'}\n

\n
\n setCreateActionOpen(false)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n {onCreateCloudAgent && (\n {\n setCreateActionOpen(false)\n setCloudCreateError('')\n setCloudCreateOpen(true)\n }}\n disabled={cloudAgentBusy}\n className=\"group rounded-2xl border border-sky-200 bg-sky-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:bg-sky-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60 dark:border-sky-500/30 dark:bg-sky-500/10 dark:hover:bg-sky-500/15\"\n >\n \n \n \n \n {zh ? '新建 Agent' : 'New Agent'}\n \n \n {zh ? '创建 WTT 托管的云端 Agent,需要 Pro 权益。' : 'Create a WTT-hosted cloud agent. Requires Pro.'}\n \n \n )}\n\n {\n setCreateActionOpen(false)\n openGroupModal()\n }}\n className=\"group rounded-2xl border border-amber-200 bg-amber-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-100 hover:shadow-md dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15\"\n >\n \n \n \n \n {zh ? '新建群聊' : 'New Group'}\n \n \n {zh ? '选择已有 Agent,创建一个私有协作 Topic。' : 'Select existing agents and create a private collaboration topic.'}\n \n \n\n {\n setCreateActionOpen(false)\n openTeamModal()\n }}\n disabled={!onNewAgentFromHost || newAgentHosts.length === 0}\n className=\"group rounded-2xl border border-fuchsia-200 bg-fuchsia-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-fuchsia-300 hover:bg-fuchsia-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15\"\n >\n \n \n \n \n {zh ? '新建团队' : 'New Team'}\n \n \n {zh ? '选择团队模板,自动 clone 多个角色 Agent 并创建群聊。' : 'Choose a team template, clone role agents, and create a group chat.'}\n \n \n
\n
\n
\n )}\n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "41afb2c410cacf4ff02c768b835a2ffee5f49fde": {"t": 1785773535.8296833, "n": 157947, "h": "e25c3f9dcf19fae4", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Search, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\nexport interface RecentTopicItem {\n topic_id: string\n topic_name?: string\n name?: string\n topic_type?: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n type?: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n last_activity_at?: string\n last_message_preview?: string\n last_sender_id?: string\n primary_agent_id?: string\n agent_ids?: string[]\n member_agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n unread_count?: number\n}\n\ninterface AgentOption {\n agent_id: string\n name?: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n typing_at?: number\n typing_topic_id?: string\n runtime_status?: string\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n recentTopics?: RecentTopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSubmitAgentOperation?: SubmitAgentOperation\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction newClientOperationId() {\n return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nexport type AgentOperationType =\n | 'cloud_agent_create'\n | 'cloud_sandbox_clone'\n | 'local_host_clone'\n | 'group_create'\n | 'team_create'\n\nexport type AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\ntype SubmitAgentOperation = (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n) => Promise\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n recentTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSubmitAgentOperation,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createActionOpen, setCreateActionOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [agentSearchQuery, setAgentSearchQuery] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: true,\n task: true,\n discuss: true,\n subscriber: true,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n const agentSearchResults = useMemo(() => {\n const query = agentSearchQuery.trim().toLowerCase()\n if (!query) return []\n const searchText = (agent: AgentOption) => [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n ].join('\\n').toLowerCase()\n const matchRank = (agent: AgentOption) => {\n const fields = [\n agent.agent_id.toLowerCase(),\n String(agent.name || '').toLowerCase(),\n String(agent.display_name || '').toLowerCase(),\n ].filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n }\n return agentOptions\n .filter((agent) => {\n return searchText(agent).includes(query)\n })\n .sort((a, b) => {\n const aRank = matchRank(a)\n const bRank = matchRank(b)\n if (aRank !== bRank) return aRank - bRank\n return a.display_name.localeCompare(b.display_name)\n })\n .slice(0, 8)\n }, [agentOptions, agentSearchQuery])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在提交群聊创建任务...' : 'Submitting group creation job...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n if (!onSubmitAgentOperation) {\n throw new Error(zh ? '后端异步 Action 未接入。' : 'Async operation action is unavailable.')\n }\n const job = await onSubmitAgentOperation(\n 'group_create',\n {\n name,\n description,\n creator_agent_id: creator,\n agent_ids: selectedIds,\n client_operation_id: newClientOperationId(),\n },\n undefined,\n (nextJob) => setGroupProgress(`${zh ? '正在创建群聊' : 'Creating group'} · ${nextJob.phase || nextJob.status || 'running'}`),\n )\n const result = (job.result || {}) as { topic?: TopicItem; topic_id?: string; member_agent_ids?: string[] }\n const topicId = String(result.topic_id || result.topic?.topic_id || '').trim()\n if (!topicId) throw new Error(zh ? '群聊创建完成但未返回 Topic ID。' : 'Group creation finished without topic id.')\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name: result.topic?.name || name,\n description: result.topic?.description || description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: result.topic?.creator_agent_id || creator,\n member_agent_ids: result.member_agent_ids || result.topic?.member_agent_ids || selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '正在提交团队创建任务...' : 'Submitting team creation job...')\n try {\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n if (!onSubmitAgentOperation) {\n throw new Error(zh ? '后端异步 Action 未接入。' : 'Async operation action is unavailable.')\n }\n const job = await onSubmitAgentOperation(\n 'team_create',\n {\n name,\n description,\n host_agent_id: teamHostId,\n adapter: teamAdapter,\n roles: roles.map((role) => ({\n id: role.id,\n label: role.label,\n display_name: role.shortLabel || role.label,\n role_template_id: role.id,\n role_template: serializeAgentRoleTemplate(role),\n })),\n client_operation_id: newClientOperationId(),\n },\n undefined,\n (nextJob) => setTeamProgress(`${zh ? '正在创建团队' : 'Creating team'} · ${nextJob.phase || nextJob.status || 'running'}`),\n )\n const result = (job.result || {}) as { topic?: TopicItem; topic_id?: string; agent_ids?: string[]; agents?: Array<{ agent_id?: string }> }\n const topicId = String(result.topic_id || result.topic?.topic_id || '').trim()\n const createdAgentIds = (Array.isArray(result.agent_ids) && result.agent_ids.length\n ? result.agent_ids\n : (result.agents || []).map((agent) => String(agent.agent_id || '').trim()).filter(Boolean))\n if (!topicId) throw new Error(zh ? '团队创建完成但未返回 Topic ID。' : 'Team creation finished without topic id.')\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name: result.topic?.name || name,\n description: result.topic?.description || description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: result.topic?.creator_agent_id || createdAgentIds[0],\n member_agent_ids: result.topic?.member_agent_ids || createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const visibleRecentTopics = useMemo(() => {\n const seen = new Set()\n return recentTopics\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .slice(0, 10)\n }, [recentTopics])\n\n const openRecentTopic = (topic: RecentTopicItem) => {\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n if (primaryAgentId && primaryAgentId !== selectedAgentId) onSelectAgent?.(primaryAgentId)\n onSelectTopic(topic.topic_id)\n }\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {createActionOpen && (\n
\n
\n
\n
\n

\n {zh ? '新建' : 'New'}\n

\n

\n {zh\n ? '选择你要创建的类型:云端 Agent、普通群聊,或带角色分工的团队。'\n : 'Choose what to create: a cloud agent, a group chat, or a role-based team.'}\n

\n
\n setCreateActionOpen(false)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n {onCreateCloudAgent && (\n {\n setCreateActionOpen(false)\n setCloudCreateError('')\n setCloudCreateOpen(true)\n }}\n disabled={cloudAgentBusy}\n className=\"group rounded-2xl border border-sky-200 bg-sky-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:bg-sky-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60 dark:border-sky-500/30 dark:bg-sky-500/10 dark:hover:bg-sky-500/15\"\n >\n \n \n \n \n {zh ? '新建 Agent' : 'New Agent'}\n \n \n {zh ? '创建 WTT 托管的云端 Agent,需要 Pro 权益。' : 'Create a WTT-hosted cloud agent. Requires Pro.'}\n \n \n )}\n\n {\n setCreateActionOpen(false)\n openGroupModal()\n }}\n className=\"group rounded-2xl border border-amber-200 bg-amber-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-100 hover:shadow-md dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15\"\n >\n \n \n \n \n {zh ? '新建群聊' : 'New Group'}\n \n \n {zh ? '选择已有 Agent,创建一个私有协作 Topic。' : 'Select existing agents and create a private collaboration topic.'}\n \n \n\n {\n setCreateActionOpen(false)\n openTeamModal()\n }}\n disabled={!onNewAgentFromHost || newAgentHosts.length === 0}\n className=\"group rounded-2xl border border-fuchsia-200 bg-fuchsia-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-fuchsia-300 hover:bg-fuchsia-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15\"\n >\n \n \n \n \n {zh ? '新建团队' : 'New Team'}\n \n \n {zh ? '选择团队模板,自动 clone 多个角色 Agent 并创建群聊。' : 'Choose a team template, clone role agents, and create a group chat.'}\n \n \n
\n
\n
\n )}\n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "88be6ec998541175f2f1c7070b91ad14d8c96d05": {"t": 1785773539.2411284, "n": 159099, "h": "bcc612004a235871", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Search, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n serializeAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\nexport interface RecentTopicItem {\n topic_id: string\n topic_name?: string\n name?: string\n topic_type?: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n type?: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n last_activity_at?: string\n last_message_preview?: string\n last_sender_id?: string\n primary_agent_id?: string\n agent_ids?: string[]\n member_agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n unread_count?: number\n}\n\ninterface AgentOption {\n agent_id: string\n name?: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n typing_at?: number\n typing_topic_id?: string\n runtime_status?: string\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n recentTopics?: RecentTopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSubmitAgentOperation?: SubmitAgentOperation\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\nfunction newClientOperationId() {\n return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nexport type AgentOperationType =\n | 'cloud_agent_create'\n | 'cloud_sandbox_clone'\n | 'local_host_clone'\n | 'group_create'\n | 'team_create'\n\nexport type AgentOperationJob = {\n job_id?: string\n operation_type?: string\n status?: string\n phase?: string\n result?: Record\n error_message?: string\n}\n\ntype SubmitAgentOperation = (\n operationType: AgentOperationType,\n payload: Record,\n idempotencyKey?: string,\n onProgress?: (job: AgentOperationJob) => void,\n) => Promise\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n recentTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSubmitAgentOperation,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createActionOpen, setCreateActionOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [agentSearchQuery, setAgentSearchQuery] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: true,\n task: true,\n discuss: true,\n subscriber: true,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n const agentSearchResults = useMemo(() => {\n const query = agentSearchQuery.trim().toLowerCase()\n if (!query) return []\n const searchText = (agent: AgentOption) => [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n ].join('\\n').toLowerCase()\n const matchRank = (agent: AgentOption) => {\n const fields = [\n agent.agent_id.toLowerCase(),\n String(agent.name || '').toLowerCase(),\n String(agent.display_name || '').toLowerCase(),\n ].filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n }\n return agentOptions\n .filter((agent) => {\n return searchText(agent).includes(query)\n })\n .sort((a, b) => {\n const aRank = matchRank(a)\n const bRank = matchRank(b)\n if (aRank !== bRank) return aRank - bRank\n return a.display_name.localeCompare(b.display_name)\n })\n .slice(0, 8)\n }, [agentOptions, agentSearchQuery])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在提交群聊创建任务...' : 'Submitting group creation job...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n if (!onSubmitAgentOperation) {\n throw new Error(zh ? '后端异步 Action 未接入。' : 'Async operation action is unavailable.')\n }\n const job = await onSubmitAgentOperation(\n 'group_create',\n {\n name,\n description,\n creator_agent_id: creator,\n agent_ids: selectedIds,\n client_operation_id: newClientOperationId(),\n },\n undefined,\n (nextJob) => setGroupProgress(`${zh ? '正在创建群聊' : 'Creating group'} · ${nextJob.phase || nextJob.status || 'running'}`),\n )\n const result = (job.result || {}) as { topic?: TopicItem; topic_id?: string; member_agent_ids?: string[] }\n const topicId = String(result.topic_id || result.topic?.topic_id || '').trim()\n if (!topicId) throw new Error(zh ? '群聊创建完成但未返回 Topic ID。' : 'Group creation finished without topic id.')\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name: result.topic?.name || name,\n description: result.topic?.description || description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: result.topic?.creator_agent_id || creator,\n member_agent_ids: result.member_agent_ids || result.topic?.member_agent_ids || selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '正在提交团队创建任务...' : 'Submitting team creation job...')\n try {\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n if (!onSubmitAgentOperation) {\n throw new Error(zh ? '后端异步 Action 未接入。' : 'Async operation action is unavailable.')\n }\n const job = await onSubmitAgentOperation(\n 'team_create',\n {\n name,\n description,\n host_agent_id: teamHostId,\n adapter: teamAdapter,\n roles: roles.map((role) => ({\n id: role.id,\n label: role.label,\n display_name: role.shortLabel || role.label,\n role_template_id: role.id,\n role_template: serializeAgentRoleTemplate(role),\n })),\n client_operation_id: newClientOperationId(),\n },\n undefined,\n (nextJob) => setTeamProgress(`${zh ? '正在创建团队' : 'Creating team'} · ${nextJob.phase || nextJob.status || 'running'}`),\n )\n const result = (job.result || {}) as { topic?: TopicItem; topic_id?: string; agent_ids?: string[]; agents?: Array<{ agent_id?: string }> }\n const topicId = String(result.topic_id || result.topic?.topic_id || '').trim()\n const createdAgentIds = (Array.isArray(result.agent_ids) && result.agent_ids.length\n ? result.agent_ids\n : (result.agents || []).map((agent) => String(agent.agent_id || '').trim()).filter(Boolean))\n if (!topicId) throw new Error(zh ? '团队创建完成但未返回 Topic ID。' : 'Team creation finished without topic id.')\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name: result.topic?.name || name,\n description: result.topic?.description || description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: result.topic?.creator_agent_id || createdAgentIds[0],\n member_agent_ids: result.topic?.member_agent_ids || createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const visibleRecentTopics = useMemo(() => {\n const seen = new Set()\n return recentTopics\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .slice(0, 10)\n }, [recentTopics])\n\n const openRecentTopic = (topic: RecentTopicItem) => {\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n if (primaryAgentId && primaryAgentId !== selectedAgentId) onSelectAgent?.(primaryAgentId)\n onSelectTopic(topic.topic_id)\n }\n\n const recentTopicAgentLabel = (topic: RecentTopicItem) => {\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const label = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n return label || agentOptions.find((agent) => agent.agent_id === primaryAgentId)?.display_name || primaryAgentId\n }\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {createActionOpen && (\n
\n
\n
\n
\n

\n {zh ? '新建' : 'New'}\n

\n

\n {zh\n ? '选择你要创建的类型:云端 Agent、普通群聊,或带角色分工的团队。'\n : 'Choose what to create: a cloud agent, a group chat, or a role-based team.'}\n

\n
\n setCreateActionOpen(false)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n {onCreateCloudAgent && (\n {\n setCreateActionOpen(false)\n setCloudCreateError('')\n setCloudCreateOpen(true)\n }}\n disabled={cloudAgentBusy}\n className=\"group rounded-2xl border border-sky-200 bg-sky-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:bg-sky-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60 dark:border-sky-500/30 dark:bg-sky-500/10 dark:hover:bg-sky-500/15\"\n >\n \n \n \n \n {zh ? '新建 Agent' : 'New Agent'}\n \n \n {zh ? '创建 WTT 托管的云端 Agent,需要 Pro 权益。' : 'Create a WTT-hosted cloud agent. Requires Pro.'}\n \n \n )}\n\n {\n setCreateActionOpen(false)\n openGroupModal()\n }}\n className=\"group rounded-2xl border border-amber-200 bg-amber-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-100 hover:shadow-md dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15\"\n >\n \n \n \n \n {zh ? '新建群聊' : 'New Group'}\n \n \n {zh ? '选择已有 Agent,创建一个私有协作 Topic。' : 'Select existing agents and create a private collaboration topic.'}\n \n \n\n {\n setCreateActionOpen(false)\n openTeamModal()\n }}\n disabled={!onNewAgentFromHost || newAgentHosts.length === 0}\n className=\"group rounded-2xl border border-fuchsia-200 bg-fuchsia-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-fuchsia-300 hover:bg-fuchsia-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15\"\n >\n \n \n \n \n {zh ? '新建团队' : 'New Team'}\n \n \n {zh ? '选择团队模板,自动 clone 多个角色 Agent 并创建群聊。' : 'Choose a team template, clone role agents, and create a group chat.'}\n \n \n
\n
\n
\n )}\n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "8aea21e3e23cf2f2fca3ca5039f2c3b5c0abeae8": {"t": 1785773542.029178, "n": 158430, "h": "41dfadd0b8a68257", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Search, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\nexport interface RecentTopicItem {\n topic_id: string\n topic_name?: string\n name?: string\n topic_type?: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n type?: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n last_activity_at?: string\n last_message_preview?: string\n last_sender_id?: string\n primary_agent_id?: string\n agent_ids?: string[]\n member_agent_ids?: string[]\n agent_labels?: Array<{ agent_id: string; display_name?: string }>\n unread_count?: number\n}\n\ninterface AgentOption {\n agent_id: string\n name?: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n typing_at?: number\n typing_topic_id?: string\n runtime_status?: string\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n recentTopics?: RecentTopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n recentTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createActionOpen, setCreateActionOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [agentSearchQuery, setAgentSearchQuery] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: true,\n task: true,\n discuss: true,\n subscriber: true,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n const agentSearchResults = useMemo(() => {\n const query = agentSearchQuery.trim().toLowerCase()\n if (!query) return []\n const searchText = (agent: AgentOption) => [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n ].join('\\n').toLowerCase()\n const matchRank = (agent: AgentOption) => {\n const fields = [\n agent.agent_id.toLowerCase(),\n String(agent.name || '').toLowerCase(),\n String(agent.display_name || '').toLowerCase(),\n ].filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n }\n return agentOptions\n .filter((agent) => {\n return searchText(agent).includes(query)\n })\n .sort((a, b) => {\n const aRank = matchRank(a)\n const bRank = matchRank(b)\n if (aRank !== bRank) return aRank - bRank\n return a.display_name.localeCompare(b.display_name)\n })\n .slice(0, 8)\n }, [agentOptions, agentSearchQuery])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: creator,\n member_agent_ids: selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: createdAgentIds[0],\n member_agent_ids: createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const visibleRecentTopics = useMemo(() => {\n const seen = new Set()\n return recentTopics\n .filter((topic) => {\n if (!topic.topic_id || seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .slice(0, 10)\n }, [recentTopics])\n\n const openRecentTopic = (topic: RecentTopicItem) => {\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n if (primaryAgentId && primaryAgentId !== selectedAgentId) onSelectAgent?.(primaryAgentId)\n onSelectTopic(topic.topic_id)\n }\n\n const recentTopicAgentLabel = (topic: RecentTopicItem) => {\n const primaryAgentId = String(topic.primary_agent_id || topic.agent_ids?.[0] || topic.member_agent_ids?.[0] || '').trim()\n const label = topic.agent_labels?.find((agent) => agent.agent_id === primaryAgentId)?.display_name\n return label || agentOptions.find((agent) => agent.agent_id === primaryAgentId)?.display_name || primaryAgentId\n }\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {createActionOpen && (\n
\n
\n
\n
\n

\n {zh ? '新建' : 'New'}\n

\n

\n {zh\n ? '选择你要创建的类型:云端 Agent、普通群聊,或带角色分工的团队。'\n : 'Choose what to create: a cloud agent, a group chat, or a role-based team.'}\n

\n
\n setCreateActionOpen(false)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n {onCreateCloudAgent && (\n {\n setCreateActionOpen(false)\n setCloudCreateError('')\n setCloudCreateOpen(true)\n }}\n disabled={cloudAgentBusy}\n className=\"group rounded-2xl border border-sky-200 bg-sky-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:bg-sky-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60 dark:border-sky-500/30 dark:bg-sky-500/10 dark:hover:bg-sky-500/15\"\n >\n \n \n \n \n {zh ? '新建 Agent' : 'New Agent'}\n \n \n {zh ? '创建 WTT 托管的云端 Agent,需要 Pro 权益。' : 'Create a WTT-hosted cloud agent. Requires Pro.'}\n \n \n )}\n\n {\n setCreateActionOpen(false)\n openGroupModal()\n }}\n className=\"group rounded-2xl border border-amber-200 bg-amber-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-100 hover:shadow-md dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15\"\n >\n \n \n \n \n {zh ? '新建群聊' : 'New Group'}\n \n \n {zh ? '选择已有 Agent,创建一个私有协作 Topic。' : 'Select existing agents and create a private collaboration topic.'}\n \n \n\n {\n setCreateActionOpen(false)\n openTeamModal()\n }}\n disabled={!onNewAgentFromHost || newAgentHosts.length === 0}\n className=\"group rounded-2xl border border-fuchsia-200 bg-fuchsia-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-fuchsia-300 hover:bg-fuchsia-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15\"\n >\n \n \n \n \n {zh ? '新建团队' : 'New Team'}\n \n \n {zh ? '选择团队模板,自动 clone 多个角色 Agent 并创建群聊。' : 'Choose a team template, clone role agents, and create a group chat.'}\n \n \n
\n
\n
\n )}\n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "674c6ce7b7124431fca94918c88b6f0ecd9cb42e": {"t": 1785773544.9321842, "n": 152501, "h": "7008a63d7b445cb1", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Search, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\ninterface AgentOption {\n agent_id: string\n name?: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n typing_at?: number\n typing_topic_id?: string\n runtime_status?: string\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createActionOpen, setCreateActionOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [agentSearchQuery, setAgentSearchQuery] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: true,\n task: true,\n discuss: true,\n subscriber: true,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n const agentSearchResults = useMemo(() => {\n const query = agentSearchQuery.trim().toLowerCase()\n if (!query) return []\n const searchText = (agent: AgentOption) => [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n ].join('\\n').toLowerCase()\n const matchRank = (agent: AgentOption) => {\n const fields = [\n agent.agent_id.toLowerCase(),\n String(agent.name || '').toLowerCase(),\n String(agent.display_name || '').toLowerCase(),\n ].filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n }\n return agentOptions\n .filter((agent) => {\n return searchText(agent).includes(query)\n })\n .sort((a, b) => {\n const aRank = matchRank(a)\n const bRank = matchRank(b)\n if (aRank !== bRank) return aRank - bRank\n return a.display_name.localeCompare(b.display_name)\n })\n .slice(0, 8)\n }, [agentOptions, agentSearchQuery])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: creator,\n member_agent_ids: selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: createdAgentIds[0],\n member_agent_ids: createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {createActionOpen && (\n
\n
\n
\n
\n

\n {zh ? '新建' : 'New'}\n

\n

\n {zh\n ? '选择你要创建的类型:云端 Agent、普通群聊,或带角色分工的团队。'\n : 'Choose what to create: a cloud agent, a group chat, or a role-based team.'}\n

\n
\n setCreateActionOpen(false)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n {onCreateCloudAgent && (\n {\n setCreateActionOpen(false)\n setCloudCreateError('')\n setCloudCreateOpen(true)\n }}\n disabled={cloudAgentBusy}\n className=\"group rounded-2xl border border-sky-200 bg-sky-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:bg-sky-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60 dark:border-sky-500/30 dark:bg-sky-500/10 dark:hover:bg-sky-500/15\"\n >\n \n \n \n \n {zh ? '新建 Agent' : 'New Agent'}\n \n \n {zh ? '创建 WTT 托管的云端 Agent,需要 Pro 权益。' : 'Create a WTT-hosted cloud agent. Requires Pro.'}\n \n \n )}\n\n {\n setCreateActionOpen(false)\n openGroupModal()\n }}\n className=\"group rounded-2xl border border-amber-200 bg-amber-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-100 hover:shadow-md dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15\"\n >\n \n \n \n \n {zh ? '新建群聊' : 'New Group'}\n \n \n {zh ? '选择已有 Agent,创建一个私有协作 Topic。' : 'Select existing agents and create a private collaboration topic.'}\n \n \n\n {\n setCreateActionOpen(false)\n openTeamModal()\n }}\n disabled={!onNewAgentFromHost || newAgentHosts.length === 0}\n className=\"group rounded-2xl border border-fuchsia-200 bg-fuchsia-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-fuchsia-300 hover:bg-fuchsia-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15\"\n >\n \n \n \n \n {zh ? '新建团队' : 'New Team'}\n \n \n {zh ? '选择团队模板,自动 clone 多个角色 Agent 并创建群聊。' : 'Choose a team template, clone role agents, and create a group chat.'}\n \n \n
\n
\n
\n )}\n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "9d40471d976f41cbac8b78a4ff49eb49ffd359ed": {"t": 1785773548.593844, "n": 152505, "h": "8f8e35aaa60a5602", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Search, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\ninterface AgentOption {\n agent_id: string\n name?: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n typing_at?: number\n typing_topic_id?: string\n runtime_status?: string\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createActionOpen, setCreateActionOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [agentSearchQuery, setAgentSearchQuery] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n const agentSearchResults = useMemo(() => {\n const query = agentSearchQuery.trim().toLowerCase()\n if (!query) return []\n const searchText = (agent: AgentOption) => [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n ].join('\\n').toLowerCase()\n const matchRank = (agent: AgentOption) => {\n const fields = [\n agent.agent_id.toLowerCase(),\n String(agent.name || '').toLowerCase(),\n String(agent.display_name || '').toLowerCase(),\n ].filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n }\n return agentOptions\n .filter((agent) => {\n return searchText(agent).includes(query)\n })\n .sort((a, b) => {\n const aRank = matchRank(a)\n const bRank = matchRank(b)\n if (aRank !== bRank) return aRank - bRank\n return a.display_name.localeCompare(b.display_name)\n })\n .slice(0, 8)\n }, [agentOptions, agentSearchQuery])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: creator,\n member_agent_ids: selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: createdAgentIds[0],\n member_agent_ids: createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {createActionOpen && (\n
\n
\n
\n
\n

\n {zh ? '新建' : 'New'}\n

\n

\n {zh\n ? '选择你要创建的类型:云端 Agent、普通群聊,或带角色分工的团队。'\n : 'Choose what to create: a cloud agent, a group chat, or a role-based team.'}\n

\n
\n setCreateActionOpen(false)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n {onCreateCloudAgent && (\n {\n setCreateActionOpen(false)\n setCloudCreateError('')\n setCloudCreateOpen(true)\n }}\n disabled={cloudAgentBusy}\n className=\"group rounded-2xl border border-sky-200 bg-sky-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:bg-sky-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60 dark:border-sky-500/30 dark:bg-sky-500/10 dark:hover:bg-sky-500/15\"\n >\n \n \n \n \n {zh ? '新建 Agent' : 'New Agent'}\n \n \n {zh ? '创建 WTT 托管的云端 Agent,需要 Pro 权益。' : 'Create a WTT-hosted cloud agent. Requires Pro.'}\n \n \n )}\n\n {\n setCreateActionOpen(false)\n openGroupModal()\n }}\n className=\"group rounded-2xl border border-amber-200 bg-amber-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-100 hover:shadow-md dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15\"\n >\n \n \n \n \n {zh ? '新建群聊' : 'New Group'}\n \n \n {zh ? '选择已有 Agent,创建一个私有协作 Topic。' : 'Select existing agents and create a private collaboration topic.'}\n \n \n\n {\n setCreateActionOpen(false)\n openTeamModal()\n }}\n disabled={!onNewAgentFromHost || newAgentHosts.length === 0}\n className=\"group rounded-2xl border border-fuchsia-200 bg-fuchsia-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-fuchsia-300 hover:bg-fuchsia-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15\"\n >\n \n \n \n \n {zh ? '新建团队' : 'New Team'}\n \n \n {zh ? '选择团队模板,自动 clone 多个角色 Agent 并创建群聊。' : 'Choose a team template, clone role agents, and create a group chat.'}\n \n \n
\n
\n
\n )}\n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "cbd9ea3602c10a22ab17699aa034e15b055402f0": {"t": 1785773552.783081, "n": 152431, "h": "7b838e5e91fb0e23", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Search, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\ninterface AgentOption {\n agent_id: string\n name?: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createActionOpen, setCreateActionOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [agentSearchQuery, setAgentSearchQuery] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n const agentSearchResults = useMemo(() => {\n const query = agentSearchQuery.trim().toLowerCase()\n if (!query) return []\n const searchText = (agent: AgentOption) => [\n agent.agent_id,\n agent.name || '',\n agent.display_name || '',\n ].join('\\n').toLowerCase()\n const matchRank = (agent: AgentOption) => {\n const fields = [\n agent.agent_id.toLowerCase(),\n String(agent.name || '').toLowerCase(),\n String(agent.display_name || '').toLowerCase(),\n ].filter(Boolean)\n if (fields.some((field) => field === query)) return 0\n if (fields.some((field) => field.startsWith(query))) return 1\n return 2\n }\n return agentOptions\n .filter((agent) => {\n return searchText(agent).includes(query)\n })\n .sort((a, b) => {\n const aRank = matchRank(a)\n const bRank = matchRank(b)\n if (aRank !== bRank) return aRank - bRank\n return a.display_name.localeCompare(b.display_name)\n })\n .slice(0, 8)\n }, [agentOptions, agentSearchQuery])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: creator,\n member_agent_ids: selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: createdAgentIds[0],\n member_agent_ids: createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {createActionOpen && (\n
\n
\n
\n
\n

\n {zh ? '新建' : 'New'}\n

\n

\n {zh\n ? '选择你要创建的类型:云端 Agent、普通群聊,或带角色分工的团队。'\n : 'Choose what to create: a cloud agent, a group chat, or a role-based team.'}\n

\n
\n setCreateActionOpen(false)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n {onCreateCloudAgent && (\n {\n setCreateActionOpen(false)\n setCloudCreateError('')\n setCloudCreateOpen(true)\n }}\n disabled={cloudAgentBusy}\n className=\"group rounded-2xl border border-sky-200 bg-sky-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:bg-sky-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60 dark:border-sky-500/30 dark:bg-sky-500/10 dark:hover:bg-sky-500/15\"\n >\n \n \n \n \n {zh ? '新建 Agent' : 'New Agent'}\n \n \n {zh ? '创建 WTT 托管的云端 Agent,需要 Pro 权益。' : 'Create a WTT-hosted cloud agent. Requires Pro.'}\n \n \n )}\n\n {\n setCreateActionOpen(false)\n openGroupModal()\n }}\n className=\"group rounded-2xl border border-amber-200 bg-amber-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-100 hover:shadow-md dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15\"\n >\n \n \n \n \n {zh ? '新建群聊' : 'New Group'}\n \n \n {zh ? '选择已有 Agent,创建一个私有协作 Topic。' : 'Select existing agents and create a private collaboration topic.'}\n \n \n\n {\n setCreateActionOpen(false)\n openTeamModal()\n }}\n disabled={!onNewAgentFromHost || newAgentHosts.length === 0}\n className=\"group rounded-2xl border border-fuchsia-200 bg-fuchsia-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-fuchsia-300 hover:bg-fuchsia-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15\"\n >\n \n \n \n \n {zh ? '新建团队' : 'New Team'}\n \n \n {zh ? '选择团队模板,自动 clone 多个角色 Agent 并创建群聊。' : 'Choose a team template, clone role agents, and create a group chat.'}\n \n \n
\n
\n
\n )}\n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "8e38f32d6315a06380ab8dda3b3a1c2d46d41b7d": {"t": 1785773557.8017335, "n": 151842, "h": "fb9286222f4d2a59", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Search, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createActionOpen, setCreateActionOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [agentSearchQuery, setAgentSearchQuery] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n const agentSearchResults = useMemo(() => {\n const query = agentSearchQuery.trim().toLowerCase()\n if (!query) return []\n return agentOptions\n .filter((agent) => {\n const id = agent.agent_id.toLowerCase()\n const name = agent.display_name.toLowerCase()\n return id.includes(query) || name.includes(query)\n })\n .sort((a, b) => {\n const aId = a.agent_id.toLowerCase()\n const bId = b.agent_id.toLowerCase()\n const aExact = aId === query ? 0 : aId.startsWith(query) ? 1 : 2\n const bExact = bId === query ? 0 : bId.startsWith(query) ? 1 : 2\n if (aExact !== bExact) return aExact - bExact\n return a.display_name.localeCompare(b.display_name)\n })\n .slice(0, 8)\n }, [agentOptions, agentSearchQuery])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: creator,\n member_agent_ids: selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: createdAgentIds[0],\n member_agent_ids: createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {createActionOpen && (\n
\n
\n
\n
\n

\n {zh ? '新建' : 'New'}\n

\n

\n {zh\n ? '选择你要创建的类型:云端 Agent、普通群聊,或带角色分工的团队。'\n : 'Choose what to create: a cloud agent, a group chat, or a role-based team.'}\n

\n
\n setCreateActionOpen(false)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n {onCreateCloudAgent && (\n {\n setCreateActionOpen(false)\n setCloudCreateError('')\n setCloudCreateOpen(true)\n }}\n disabled={cloudAgentBusy}\n className=\"group rounded-2xl border border-sky-200 bg-sky-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:bg-sky-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60 dark:border-sky-500/30 dark:bg-sky-500/10 dark:hover:bg-sky-500/15\"\n >\n \n \n \n \n {zh ? '新建 Agent' : 'New Agent'}\n \n \n {zh ? '创建 WTT 托管的云端 Agent,需要 Pro 权益。' : 'Create a WTT-hosted cloud agent. Requires Pro.'}\n \n \n )}\n\n {\n setCreateActionOpen(false)\n openGroupModal()\n }}\n className=\"group rounded-2xl border border-amber-200 bg-amber-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-100 hover:shadow-md dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15\"\n >\n \n \n \n \n {zh ? '新建群聊' : 'New Group'}\n \n \n {zh ? '选择已有 Agent,创建一个私有协作 Topic。' : 'Select existing agents and create a private collaboration topic.'}\n \n \n\n {\n setCreateActionOpen(false)\n openTeamModal()\n }}\n disabled={!onNewAgentFromHost || newAgentHosts.length === 0}\n className=\"group rounded-2xl border border-fuchsia-200 bg-fuchsia-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-fuchsia-300 hover:bg-fuchsia-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15\"\n >\n \n \n \n \n {zh ? '新建团队' : 'New Team'}\n \n \n {zh ? '选择团队模板,自动 clone 多个角色 Agent 并创建群聊。' : 'Choose a team template, clone role agents, and create a group chat.'}\n \n \n
\n
\n
\n )}\n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "c721dd115a9ea719cacd12d7fce965cafcc1b863": {"t": 1785773561.553264, "n": 147031, "h": "c4c82c503848c7a8", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createActionOpen, setCreateActionOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: creator,\n member_agent_ids: selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: createdAgentIds[0],\n member_agent_ids: createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {createActionOpen && (\n
\n
\n
\n
\n

\n {zh ? '新建' : 'New'}\n

\n

\n {zh\n ? '选择你要创建的类型:云端 Agent、普通群聊,或带角色分工的团队。'\n : 'Choose what to create: a cloud agent, a group chat, or a role-based team.'}\n

\n
\n setCreateActionOpen(false)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n {onCreateCloudAgent && (\n {\n setCreateActionOpen(false)\n setCloudCreateError('')\n setCloudCreateOpen(true)\n }}\n disabled={cloudAgentBusy}\n className=\"group rounded-2xl border border-sky-200 bg-sky-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:bg-sky-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60 dark:border-sky-500/30 dark:bg-sky-500/10 dark:hover:bg-sky-500/15\"\n >\n \n \n \n \n {zh ? '新建 Agent' : 'New Agent'}\n \n \n {zh ? '创建 WTT 托管的云端 Agent,需要 Pro 权益。' : 'Create a WTT-hosted cloud agent. Requires Pro.'}\n \n \n )}\n\n {\n setCreateActionOpen(false)\n openGroupModal()\n }}\n className=\"group rounded-2xl border border-amber-200 bg-amber-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-100 hover:shadow-md dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15\"\n >\n \n \n \n \n {zh ? '新建群聊' : 'New Group'}\n \n \n {zh ? '选择已有 Agent,创建一个私有协作 Topic。' : 'Select existing agents and create a private collaboration topic.'}\n \n \n\n {\n setCreateActionOpen(false)\n openTeamModal()\n }}\n disabled={!onNewAgentFromHost || newAgentHosts.length === 0}\n className=\"group rounded-2xl border border-fuchsia-200 bg-fuchsia-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-fuchsia-300 hover:bg-fuchsia-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15\"\n >\n \n \n \n \n {zh ? '新建团队' : 'New Team'}\n \n \n {zh ? '选择团队模板,自动 clone 多个角色 Agent 并创建群聊。' : 'Choose a team template, clone role agents, and create a group chat.'}\n \n \n
\n
\n
\n )}\n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "9b614ca3ce830afc28d52970fd7b35c4501f7eb1": {"t": 1785773563.468186, "n": 147008, "h": "58d109c4d186e5fe", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createActionOpen, setCreateActionOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: creator,\n member_agent_ids: selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: createdAgentIds[0],\n member_agent_ids: createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {createActionOpen && (\n
\n
\n
\n
\n

\n {zh ? '新建' : 'New'}\n

\n

\n {zh\n ? '选择你要创建的类型:云端 Agent、普通群聊,或带角色分工的团队。'\n : 'Choose what to create: a cloud agent, a group chat, or a role-based team.'}\n

\n
\n setCreateActionOpen(false)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n {onCreateCloudAgent && (\n {\n setCreateActionOpen(false)\n setCloudCreateError('')\n setCloudCreateOpen(true)\n }}\n disabled={cloudAgentBusy}\n className=\"group rounded-2xl border border-sky-200 bg-sky-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-sky-300 hover:bg-sky-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60 dark:border-sky-500/30 dark:bg-sky-500/10 dark:hover:bg-sky-500/15\"\n >\n \n \n \n \n {zh ? '新建 Agent' : 'New Agent'}\n \n \n {zh ? '创建 WTT 托管的云端 Agent,需要 Pro 权益。' : 'Create a WTT-hosted cloud agent. Requires Pro.'}\n \n \n )}\n\n {\n setCreateActionOpen(false)\n openGroupModal()\n }}\n className=\"group rounded-2xl border border-amber-200 bg-amber-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-100 hover:shadow-md dark:border-amber-500/30 dark:bg-amber-500/10 dark:hover:bg-amber-500/15\"\n >\n \n \n \n \n {zh ? '新建群聊' : 'New Group'}\n \n \n {zh ? '选择已有 Agent,创建一个私有协作 Topic。' : 'Select existing agents and create a private collaboration topic.'}\n \n \n\n {\n setCreateActionOpen(false)\n openTeamModal()\n }}\n disabled={!onNewAgentFromHost || newAgentHosts.length === 0}\n className=\"group rounded-2xl border border-fuchsia-200 bg-fuchsia-50/80 p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-fuchsia-300 hover:bg-fuchsia-100 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-fuchsia-500/30 dark:bg-fuchsia-500/10 dark:hover:bg-fuchsia-500/15\"\n >\n \n \n \n \n {zh ? '新建团队' : 'New Team'}\n \n \n {zh ? '选择团队模板,自动 clone 多个角色 Agent 并创建群聊。' : 'Choose a team template, clone role agents, and create a group chat.'}\n \n \n
\n
\n
\n )}\n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "85e38f9386f4146583176735f293662f3fc5430b": {"t": 1785773586.7267287, "n": 146669, "h": "404144367ab4bee7", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [createMenuOpen, setCreateMenuOpen] = useState(false)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n setCreateMenuOpen(false)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: creator,\n member_agent_ids: selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: createdAgentIds[0],\n member_agent_ids: createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "30e71af800cce8877ef9be1e6ecd770c29ef4e26": {"t": 1785773589.2282107, "n": 144024, "h": "a419287b6cf3bc6d", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\nfunction isDiscussionGroupTopic(topic: TopicItem) {\n const name = String(topic.name || '').trim()\n const description = String(topic.description || '').toLowerCase()\n if (!['discussion', 'collaborative'].includes(topic.topic_type)) return false\n if (topic.task_id || topic.task_type || topic.task_mode || topic.exec_mode || topic.runner_agent_id) return false\n if (/^TASK-[a-f0-9]{8}\\b/i.test(name)) return false\n if (description.includes('general task') || description.includes('task_id') || description.includes('task type')) return false\n return true\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: creator,\n member_agent_ids: selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: createdAgentIds[0],\n member_agent_ids: createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter(isDiscussionGroupTopic)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "b185e9844a9401123c3c85154038dac858782056": {"t": 1785773591.3526433, "n": 143526, "h": "eb7fdd74124068ea", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n member_agent_ids?: string[]\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n groupTopics?: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onTopicCreated?: (topic: TopicItem) => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n groupTopics = [],\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onTopicCreated,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [groupTopicsCollapsed, setGroupTopicsCollapsed] = useState(false)\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: creator,\n member_agent_ids: selectedIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const optimisticTopic: TopicItem = {\n topic_id: topicId,\n name,\n description,\n topic_type: 'discussion',\n unread_count: 0,\n can_delete: true,\n creator_agent_id: createdAgentIds[0],\n member_agent_ids: createdAgentIds,\n last_activity_at: new Date().toISOString(),\n }\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n await onTopicCreated?.(optimisticTopic)\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const agentRailGroupTopics = useMemo(() => {\n const seen = new Set()\n return groupTopics\n .filter((topic) => ['discussion', 'collaborative'].includes(topic.topic_type) && !topic.task_id)\n .filter((topic) => {\n if (seen.has(topic.topic_id)) return false\n seen.add(topic.topic_id)\n return true\n })\n .sort((a, b) => {\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }, [groupTopics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "8e555cbb39e5712a9d15b82e2fb63a488402fa42": {"t": 1785773593.6228597, "n": 137180, "h": "fecc5c4d61123883", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "88f486535bb232042ab4e078f5e8047dcc0b9f61": {"t": 1785773598.0443006, "n": 137197, "h": "ad6d13602506fae6", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupProgress, setGroupProgress] = useState('')\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupProgress('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n setGroupProgress(zh ? '正在创建群聊 Topic...' : 'Creating group topic...')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n const memberIds = selectedIds.filter((id) => id !== creator)\n for (let index = 0; index < memberIds.length; index += 1) {\n setGroupProgress(`${zh ? '正在加入成员' : 'Adding member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n setGroupProgress(zh ? '正在刷新 Topic 列表...' : 'Refreshing topic list...')\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n setGroupProgress('')\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n const memberIds = createdAgentIds.slice(1)\n for (let index = 0; index < memberIds.length; index += 1) {\n setTeamProgress(`${zh ? '正在加入团队成员' : 'Adding team member'} ${index + 1}/${memberIds.length}: ${memberIds[index]}`)\n await joinAgentToTopic(topicId, memberIds[index])\n }\n setTeamProgress(zh ? '正在刷新 Agent 和 Topic 列表...' : 'Refreshing agents and topics...')\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n {groupProgress && (\n
\n \n {groupProgress}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n \n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "bb40976e63320a97b8397ca99a83e036a4080523": {"t": 1785773599.796103, "n": 136029, "h": "8a9e26cd96f765c5", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamRolePlan = {\n role: AgentRoleTemplateId\n mission: string\n deliverable: string\n}\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n rolePlan: TeamRolePlan[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n rolePlan: [\n { role: 'research', mission: '建立论文证据库,核查核心结论、数据集、实验设置和引用边界。', deliverable: '论文要点表、证据强弱、待验证假设。' },\n { role: 'engineering', mission: '把论文方法拆成可复现实现路径,识别代码、数据、环境和工程风险。', deliverable: '复现步骤、工程实现草图、依赖清单。' },\n { role: 'qa', mission: '设计复现实验和消融检查,专门找失败模式、指标陷阱和边界条件。', deliverable: '实验验证矩阵、风险清单、验收标准。' },\n { role: 'media_creator', mission: '把研究结论整理成可读综述、汇报稿或对外传播版本。', deliverable: '综述大纲、摘要、图文表达建议。' },\n { role: 'product', mission: '评估论文方法的应用场景、用户价值和产品化优先级。', deliverable: '应用场景、机会点、下一步产品实验。' },\n ],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n rolePlan: [\n { role: 'product', mission: '定义需求边界、用户故事、优先级和验收标准。', deliverable: 'PRD 摘要、用户路径、验收清单。' },\n { role: 'engineering', mission: '制定架构、接口、实现拆分和技术风险控制。', deliverable: '技术方案、任务拆分、代码实现计划。' },\n { role: 'designer', mission: '负责信息层级、交互路径、视觉方向和可用性评审。', deliverable: '交互草图、设计原则、体验风险。' },\n { role: 'qa', mission: '设计测试矩阵,覆盖边界条件、回归风险和上线验证。', deliverable: '测试用例、回归范围、验收报告。' },\n { role: 'operator', mission: '规划上线节奏、用户触达、指标监控和复盘。', deliverable: '发布计划、运营动作、数据看板口径。' },\n ],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n rolePlan: [\n { role: 'product', mission: '把用户需求翻译成明确的编码任务和验收标准。', deliverable: '任务边界、优先级、验收条件。' },\n { role: 'engineering', mission: '承担主要实现、debug、重构和工程质量控制。', deliverable: '代码方案、补丁计划、风险点。' },\n { role: 'research', mission: '查找官方文档、最佳实践和技术选型依据。', deliverable: '资料链接、方案对比、引用依据。' },\n { role: 'qa', mission: '跑测试、找边界、验证修复是否破坏现有功能。', deliverable: '测试命令、失败模式、回归结论。' },\n { role: 'ceo', mission: '负责节奏、取舍和最后合并前的风险判断。', deliverable: '执行顺序、阻塞项、最终决策建议。' },\n ],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n rolePlan: [\n { role: 'research', mission: '收集事实、案例、竞品和背景材料,确保内容可信。', deliverable: '素材库、事实核查、参考链接。' },\n { role: 'media_creator', mission: '设计选题、标题、脚本结构、叙事节奏和传播钩子。', deliverable: '选题方案、标题组、正文/脚本初稿。' },\n { role: 'designer', mission: '提供封面、配图、排版和视觉风格建议。', deliverable: '视觉方向、封面文案、版式建议。' },\n { role: 'operator', mission: '规划发布节奏、渠道策略、互动方式和数据复盘。', deliverable: '发布计划、运营清单、复盘指标。' },\n { role: 'sales', mission: '优化转化话术、行动号召和商业落点。', deliverable: 'CTA、转化路径、异议处理话术。' },\n ],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n rolePlan: [\n { role: 'product', mission: '明确目标用户、核心问题、产品定位和功能优先级。', deliverable: '用户画像、价值主张、路线图。' },\n { role: 'operator', mission: '设计增长实验、触达策略、留存机制和指标追踪。', deliverable: '实验计划、运营节奏、指标口径。' },\n { role: 'sales', mission: '验证客户需求、成交路径、异议和价格接受度。', deliverable: '客户发现问题、销售话术、成交阻塞。' },\n { role: 'finance', mission: '测算成本、收入、毛利、现金流和 ROI。', deliverable: '单位经济模型、预算、风险暴露。' },\n { role: 'investor', mission: '评估市场空间、竞争壁垒、增长质量和融资叙事。', deliverable: '投资备忘录、关键假设、风险判断。' },\n ],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n rolePlan: [\n { role: 'lawyer', mission: '识别法律关系、合同风险、隐私合规和证据要求。', deliverable: '风险清单、条款建议、证据计划。' },\n { role: 'finance', mission: '评估潜在赔付、成本、预算和财务影响。', deliverable: '成本测算、赔付暴露、财务口径。' },\n { role: 'product', mission: '把合规要求转成产品流程、提示、权限和用户体验修改。', deliverable: '流程调整、产品约束、上线检查。' },\n { role: 'research', mission: '补充法规、案例、监管口径和行业实践。', deliverable: '法规引用、案例摘要、边界说明。' },\n { role: 'ceo', mission: '在法律、业务和成本之间做决策排序。', deliverable: '处理方案、负责人、时间表。' },\n ],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n rolePlan: [\n { role: 'doctor', mission: '识别风险信号、就诊准备、检查治疗常识和医学边界。', deliverable: '风险提示、就诊问题清单、资料摘要。' },\n { role: 'research', mission: '查找可信医学资料来源,标注证据等级和适用范围。', deliverable: '资料来源、证据边界、待确认问题。' },\n { role: 'teacher', mission: '把医学概念用用户能理解的方式解释清楚。', deliverable: '通俗解释、学习路径、注意事项。' },\n { role: 'operator', mission: '整理长期跟踪、生活方式、复查提醒和记录模板。', deliverable: '跟踪清单、提醒计划、记录表。' },\n ],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n rolePlan: [\n { role: 'architect', mission: '负责空间功能、动线、采光、结构约束和施工可行性。', deliverable: '空间方案、约束清单、落地风险。' },\n { role: 'designer', mission: '负责风格、材料、视觉体验和用户感受。', deliverable: '风格板、材料建议、视觉原则。' },\n { role: 'finance', mission: '控制预算、造价、采购优先级和成本风险。', deliverable: '预算表、成本取舍、采购建议。' },\n { role: 'qa', mission: '检查施工安全、质量隐患、验收点和维护风险。', deliverable: '验收清单、风险点、检查流程。' },\n { role: 'product', mission: '对齐真实生活场景、用户习惯和功能优先级。', deliverable: '场景需求、功能排序、体验验收。' },\n ],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction buildTeamRoleTemplate(plan: TeamRolePlan, template: TeamTemplate): AgentRoleTemplate {\n const base = getAgentRoleTemplate(plan.role)\n const description = `${base.description} 团队分工:${plan.mission} 主要交付:${plan.deliverable}`\n return {\n ...base,\n description,\n systemPrompt: [\n base.systemPrompt,\n '',\n `你当前属于「${template.title}」协作团队。`,\n `你的明确分工:${plan.mission}`,\n `你的主要交付物:${plan.deliverable}`,\n '协作要求:先完成自己职责内的判断,再用简洁结构同步给团队;不要越权替代其他角色,但可以指出需要其他角色补充的问题。',\n ].join('\\n'),\n }\n}\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentsToTopicSequentially = async (topicId: string, agentIds: string[]) => {\n for (const agentId of agentIds) {\n await joinAgentToTopic(topicId, agentId)\n }\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n await joinAgentsToTopicSequentially(topicId, selectedIds.filter((id) => id !== creator))\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const rolePlans = template.rolePlan.slice(0, 5)\n const roles = rolePlans.map((plan) => buildTeamRoleTemplate(plan, template))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '角色分工:' : 'Role assignments:',\n ...rolePlans.map((plan, index) => {\n const role = getAgentRoleTemplate(plan.role)\n return `${index + 1}. ${role.label}: ${plan.mission} 交付:${plan.deliverable}`\n }),\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n await joinAgentsToTopicSequentially(topicId, createdAgentIds.slice(1))\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.rolePlan.slice(0, 5).map((plan) => getAgentRoleTemplate(plan.role).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n {template.rolePlan.slice(0, 2).map((plan) => `${getAgentRoleTemplate(plan.role).shortLabel}: ${plan.deliverable}`).join(' · ')}\n \n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '角色分工' : 'Role assignments'}

\n
\n {selectedTeamTemplate.rolePlan.map((plan) => {\n const role = getAgentRoleTemplate(plan.role)\n return (\n

\n {role.shortLabel}: {plan.mission}\n

\n )\n })}\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "e46608d048b9936fdd2184e73c8d19157fcc45a7": {"t": 1785773602.4491634, "n": 130902, "h": "8e756e7bb2c91ac1", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n roles: AgentRoleTemplateId[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n roles: ['research', 'engineering', 'qa', 'media_creator', 'product'],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n roles: ['product', 'engineering', 'designer', 'qa', 'operator'],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n roles: ['engineering', 'qa', 'research', 'product', 'ceo'],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n roles: ['media_creator', 'research', 'designer', 'operator', 'sales'],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n roles: ['product', 'operator', 'sales', 'finance', 'investor'],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n roles: ['lawyer', 'finance', 'product', 'ceo', 'research'],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n roles: ['doctor', 'research', 'teacher', 'operator'],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n roles: ['architect', 'designer', 'finance', 'qa', 'product'],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n const newAgentHostFolders = useMemo(() => {\n const hostSet = new Set(newAgentHosts.map((agent) => agent.agent_id))\n return agentFolders\n .map((folder) => ({ ...folder, agents: folder.agents.filter((agent) => hostSet.has(agent.agent_id)) }))\n .filter((folder) => folder.agents.length > 0)\n }, [agentFolders, newAgentHosts])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentsToTopicSequentially = async (topicId: string, agentIds: string[]) => {\n for (const agentId of agentIds) {\n await joinAgentToTopic(topicId, agentId)\n }\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n await joinAgentsToTopicSequentially(topicId, selectedIds.filter((id) => id !== creator))\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const roles = template.roles.slice(0, 5).map((roleId) => getAgentRoleTemplate(roleId))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n await joinAgentsToTopicSequentially(topicId, createdAgentIds.slice(1))\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentFolders.map((folder) => {\n const onlineCount = folder.agents.filter((agent) => isAgentOnline(agent.agent_id)).length\n const selectedCount = folder.agents.filter((agent) => groupAgentIds.includes(agent.agent_id)).length\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n \n
\n \n {folderInitial}\n \n \n {folder.label}\n \n {onlineCount}/{folder.agents.length} online · {selectedCount} selected\n \n \n {\n const room = Math.max(0, MAX_GROUP_AGENTS - groupAgentIds.length)\n const folderIds = folder.agents.map((agent) => agent.agent_id)\n const missing = folderIds.filter((id) => !groupAgentIds.includes(id)).slice(0, room)\n setGroupAgentIds((prev) => Array.from(new Set([...prev, ...missing])))\n }}\n className=\"rounded-full bg-amber-50 px-2 py-1 text-[10px] font-black text-amber-700 transition hover:bg-amber-100 disabled:opacity-50 dark:bg-amber-500/10 dark:text-amber-200\"\n >\n {zh ? '选目录' : 'Pick'}\n \n
\n
\n {folder.agents.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const online = isAgentOnline(agent.agent_id)\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`relative min-w-0 rounded-xl border px-2 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n \n {agent.display_name || agent.agent_id}\n {role.shortLabel}\n \n )\n })}\n
\n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.roles.slice(0, 5).map((roleId) => getAgentRoleTemplate(roleId).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n )\n })}\n
\n
\n\n
\n \n\n
\n
{zh ? '运行主机' : 'Host'}
\n {newAgentHostFolders.length === 0 ? (\n
\n {zh ? '没有在线可 clone 主机。请先启动一个 Codex / Claude Code / Gemini Agent。' : 'No online clone host. Start a Codex / Claude Code / Gemini agent first.'}\n
\n ) : (\n
\n {newAgentHostFolders.map((folder) => {\n const folderTone = hostFolderTone(folder.key)\n const folderInitial = (folder.label.trim()[0] || '?').toUpperCase()\n return (\n
\n
\n \n {folderInitial}\n \n \n {folder.label}\n \n
\n
\n {folder.agents.map((agent) => {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const adapter = normalizeNewAgentAdapter(runtime)\n const active = teamHostId === agent.agent_id\n return (\n {\n setTeamHostId(agent.agent_id)\n setTeamAdapter((adapter || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n }}\n className={`rounded-xl border px-2 py-2 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-900/70 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n title={`${agent.display_name || agent.agent_id} · ${agent.agent_id}`}\n >\n {agent.display_name || agent.agent_id}\n {adapter || 'adapter'} · online\n \n )\n })}\n
\n
\n )\n })}\n
\n )}\n
\n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "98ed7e9d3560a7f8b3dd6a3b00d9f264059734fc": {"t": 1785773604.3270643, "n": 125204, "h": "edaedbb903d6f333", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nconst MAX_GROUP_AGENTS = 30\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n roles: AgentRoleTemplateId[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n roles: ['research', 'engineering', 'qa', 'media_creator', 'product'],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n roles: ['product', 'engineering', 'designer', 'qa', 'operator'],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n roles: ['engineering', 'qa', 'research', 'product', 'ceo'],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n roles: ['media_creator', 'research', 'designer', 'operator', 'sales'],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n roles: ['product', 'operator', 'sales', 'finance', 'investor'],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n roles: ['lawyer', 'finance', 'product', 'ceo', 'research'],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n roles: ['doctor', 'research', 'teacher', 'operator'],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n roles: ['architect', 'designer', 'finance', 'qa', 'product'],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : prev.length >= MAX_GROUP_AGENTS\n ? prev\n : [...prev, agentId]\n ))\n }\n\n const joinAgentsToTopicSequentially = async (topicId: string, agentIds: string[]) => {\n for (const agentId of agentIds) {\n await joinAgentToTopic(topicId, agentId)\n }\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n if (selectedIds.length > MAX_GROUP_AGENTS) {\n setGroupError(zh ? `一次群聊最多选择 ${MAX_GROUP_AGENTS} 个 Agent。` : `Select up to ${MAX_GROUP_AGENTS} agents for one group.`)\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n await joinAgentsToTopicSequentially(topicId, selectedIds.filter((id) => id !== creator))\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const roles = template.roles.slice(0, 5).map((roleId) => getAgentRoleTemplate(roleId))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n await joinAgentsToTopicSequentially(topicId, createdAgentIds.slice(1))\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.slice(0, MAX_GROUP_AGENTS).map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? `全选前 ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)} 个` : `Select first ${Math.min(agentOptions.length, MAX_GROUP_AGENTS)}`}\n \n
\n

\n {zh\n ? `已选 ${groupAgentIds.length}/${MAX_GROUP_AGENTS} 个。大量 Agent 会增加消息扇出成本,建议只拉入本次需要协作的 Agent。`\n : `Selected ${groupAgentIds.length}/${MAX_GROUP_AGENTS}. Large groups increase fan-out cost; invite only agents needed for this task.`}\n

\n
\n {agentOptions.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n >\n {agent.display_name || agent.agent_id}\n \n {role.shortLabel} · {runtime?.hostname || (zh ? '未知主机' : 'Unknown host')}\n \n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.roles.slice(0, 5).map((roleId) => getAgentRoleTemplate(roleId).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n )\n })}\n
\n
\n\n
\n \n\n \n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "a8ecb25a226ec7f159c9162c0ddb48d70494c873": {"t": 1785773606.2202923, "n": 124256, "h": "aa9821b1e6d94dbf", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (\n hostAgentId: string,\n role: AgentRoleTemplate,\n adapter: 'claude-code' | 'codex' | 'gemini',\n options?: { select?: boolean; alert?: boolean },\n ) => string | void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onTopicsRefresh?: () => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\ntype TeamTemplate = {\n id: string\n title: string\n titleEn: string\n description: string\n descriptionEn: string\n roles: AgentRoleTemplateId[]\n workflow: string[]\n}\n\nconst TEAM_TEMPLATES: TeamTemplate[] = [\n {\n id: 'paper-research',\n title: '论文研究团队',\n titleEn: 'Paper Research Team',\n description: '适合读论文、找证据、复现实验和形成研究综述。',\n descriptionEn: 'For reading papers, checking evidence, reproducing ideas, and writing research summaries.',\n roles: ['research', 'engineering', 'qa', 'media_creator', 'product'],\n workflow: ['研究员收集论文和事实证据', '研发 Agent 梳理方法与可复现路径', '测试 Agent 检查实验风险和验证方案', '写作 Agent 形成综述/报告', '产品 Agent 提炼应用场景和下一步'],\n },\n {\n id: 'rd',\n title: '研发团队',\n titleEn: 'R&D Team',\n description: '适合从需求到架构、实现、测试和上线复盘。',\n descriptionEn: 'For product requirements, architecture, implementation, testing, and release review.',\n roles: ['product', 'engineering', 'designer', 'qa', 'operator'],\n workflow: ['产品 Agent 明确需求和验收标准', '设计 Agent 给出交互/视觉方向', '研发 Agent 拆解架构和实现', '测试 Agent 输出回归矩阵', '运营 Agent 规划上线和数据复盘'],\n },\n {\n id: 'coding',\n title: 'Coding 团队',\n titleEn: 'Coding Team',\n description: '适合代码实现、debug、review、测试和交付。',\n descriptionEn: 'For implementation, debugging, code review, testing, and delivery.',\n roles: ['engineering', 'qa', 'research', 'product', 'ceo'],\n workflow: ['产品 Agent 定义任务边界', '研发 Agent 实现核心代码', '研究 Agent 查资料和技术选型', '测试 Agent 跑测试和找边界', '总经理 Agent 汇总进度、风险和下一步'],\n },\n {\n id: 'writing',\n title: '写作团队',\n titleEn: 'Writing Team',\n description: '适合文章、脚本、商业文案和长内容生产。',\n descriptionEn: 'For articles, scripts, copywriting, and long-form content production.',\n roles: ['media_creator', 'research', 'designer', 'operator', 'sales'],\n workflow: ['研究 Agent 收集素材和事实', '自媒体 Agent 设计选题/标题/脚本', '设计 Agent 建议视觉表达', '运营 Agent 规划发布节奏', '销售 Agent 优化转化话术'],\n },\n {\n id: 'product-growth',\n title: '产品增长团队',\n titleEn: 'Product Growth Team',\n description: '适合产品定位、增长实验、用户运营和商业化。',\n descriptionEn: 'For product positioning, growth experiments, user operations, and monetization.',\n roles: ['product', 'operator', 'sales', 'finance', 'investor'],\n workflow: ['产品 Agent 明确用户和功能策略', '运营 Agent 设计增长实验', '销售 Agent 验证价值主张', '财务 Agent 测算成本收益', '投资人 Agent 评估市场空间和壁垒'],\n },\n {\n id: 'legal-compliance',\n title: '法律合规团队',\n titleEn: 'Legal & Compliance Team',\n description: '适合合同、隐私、商业风险和证据整理。',\n descriptionEn: 'For contracts, privacy, business risk, and evidence planning.',\n roles: ['lawyer', 'finance', 'product', 'ceo', 'research'],\n workflow: ['律师 Agent 梳理法律关系和风险', '财务 Agent 评估成本和赔付暴露', '产品 Agent 调整流程和提示', '研究 Agent 补充监管/案例材料', '总经理 Agent 制定决策和执行节奏'],\n },\n {\n id: 'medical-health',\n title: '医疗健康团队',\n titleEn: 'Medical Health Team',\n description: '适合健康资料整理、就诊准备和风险提示。',\n descriptionEn: 'For health information summaries, clinic preparation, and risk warnings.',\n roles: ['doctor', 'research', 'teacher', 'operator'],\n workflow: ['医生 Agent 识别风险信号和就诊准备', '研究 Agent 补充资料来源和边界', '教师 Agent 用易懂方式解释概念', '运营 Agent 整理长期跟踪清单'],\n },\n {\n id: 'architecture',\n title: '建筑方案团队',\n titleEn: 'Architecture Planning Team',\n description: '适合空间规划、材料风格、预算和施工约束。',\n descriptionEn: 'For space planning, materials, budget, and construction constraints.',\n roles: ['architect', 'designer', 'finance', 'qa', 'product'],\n workflow: ['建筑师 Agent 明确空间和结构约束', '设计师 Agent 给出风格和体验方向', '财务 Agent 控制造价', '测试 Agent 检查施工/安全风险', '产品 Agent 对齐用户生活场景'],\n },\n]\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onTopicsRefresh,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [groupOpen, setGroupOpen] = useState(false)\n const [groupName, setGroupName] = useState('')\n const [groupDesc, setGroupDesc] = useState('')\n const [groupAgentIds, setGroupAgentIds] = useState([])\n const [groupBusy, setGroupBusy] = useState(false)\n const [groupError, setGroupError] = useState('')\n const [teamOpen, setTeamOpen] = useState(false)\n const [teamTemplateId, setTeamTemplateId] = useState(TEAM_TEMPLATES[0]?.id || '')\n const [teamName, setTeamName] = useState(TEAM_TEMPLATES[0]?.title || '')\n const [teamHostId, setTeamHostId] = useState('')\n const [teamAdapter, setTeamAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [teamBusy, setTeamBusy] = useState(false)\n const [teamProgress, setTeamProgress] = useState('')\n const [teamError, setTeamError] = useState('')\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const selectedTeamTemplate = useMemo(\n () => TEAM_TEMPLATES.find((template) => template.id === teamTemplateId) || TEAM_TEMPLATES[0],\n [teamTemplateId],\n )\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const openGroupModal = () => {\n const defaults = selectedAgentId ? [selectedAgentId] : agentOptions.slice(0, 3).map((agent) => agent.agent_id)\n setGroupName(zh ? '新的 Agent 群聊' : 'New Agent Group')\n setGroupDesc(zh ? '由多个已绑定 Agent 组成的协作群聊。' : 'A collaboration topic with selected bound agents.')\n setGroupAgentIds(defaults)\n setGroupError('')\n setGroupOpen(true)\n }\n\n const openTeamModal = () => {\n const template = selectedTeamTemplate || TEAM_TEMPLATES[0]\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId) || newAgentHosts[0]\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n setTeamHostId(selectedHost?.agent_id || '')\n setTeamAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[selectedHost?.agent_id || '']) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setTeamProgress('')\n setTeamError('')\n setTeamOpen(true)\n }\n\n const toggleGroupAgent = (agentId: string) => {\n setGroupAgentIds((prev) => (\n prev.includes(agentId)\n ? prev.filter((id) => id !== agentId)\n : [...prev, agentId]\n ))\n }\n\n const joinAgentToTopic = async (topicId: string, agentId: string) => {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/topics/${encodeURIComponent(topicId)}/join?agent_id=${encodeURIComponent(agentId)}`, {\n method: 'POST',\n headers: userToken ? { Authorization: `Bearer ${userToken}` } : undefined,\n })\n if (!response.ok) {\n const data = await response.json().catch(() => ({}))\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || `Failed to add ${agentId} to topic`)\n }\n }\n\n const createPrivateDiscussionTopic = async (name: string, description: string, creatorAgentId: string) => {\n const topic = await wttApi.createTopic({\n name,\n description,\n type: 'discussion',\n visibility: 'private',\n join_method: 'invite_only',\n creator_agent_id: creatorAgentId,\n }, userToken)\n const topicId = String((topic as unknown as { topic_id?: string; id?: string }).topic_id || topic.id || '').trim()\n if (!topicId) throw new Error(zh ? '后端未返回 Topic ID。' : 'Backend did not return topic id.')\n return topicId\n }\n\n const createGroupChat = async () => {\n const selectedIds = Array.from(new Set(groupAgentIds)).filter(Boolean)\n if (!userToken) {\n setGroupError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (selectedIds.length === 0) {\n setGroupError(zh ? '至少选择一个 Agent。' : 'Select at least one Agent.')\n return\n }\n const name = groupName.trim()\n const description = groupDesc.trim() || (zh ? 'Agent 群聊。' : 'Agent group chat.')\n if (!name) {\n setGroupError(zh ? '请输入群聊名称。' : 'Please enter a group name.')\n return\n }\n setGroupBusy(true)\n setGroupError('')\n try {\n const creator = selectedIds.includes(selectedAgentId) ? selectedAgentId : selectedIds[0]\n const topicId = await createPrivateDiscussionTopic(name, description, creator)\n await Promise.all(selectedIds.filter((id) => id !== creator).map((agentId) => joinAgentToTopic(topicId, agentId)))\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setGroupOpen(false)\n } catch (error) {\n setGroupError(error instanceof Error ? error.message : (zh ? '创建群聊失败。' : 'Failed to create group chat.'))\n } finally {\n setGroupBusy(false)\n }\n }\n\n const createTeamFromTemplate = async () => {\n const template = selectedTeamTemplate\n if (!template || !onNewAgentFromHost) return\n if (!userToken) {\n setTeamError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n if (!teamHostId) {\n setTeamError(zh ? '请选择一个在线主机。' : 'Choose an online host.')\n return\n }\n const name = teamName.trim() || (zh ? template.title : template.titleEn)\n const roles = template.roles.slice(0, 5).map((roleId) => getAgentRoleTemplate(roleId))\n setTeamBusy(true)\n setTeamError('')\n setTeamProgress(zh ? '开始创建团队 Agent...' : 'Creating team agents...')\n try {\n const createdAgentIds: string[] = []\n for (let index = 0; index < roles.length; index += 1) {\n const role = roles[index]\n setTeamProgress(`${zh ? '正在创建' : 'Creating'} ${index + 1}/${roles.length}: ${role.label}`)\n const newAgentId = await onNewAgentFromHost(teamHostId, role, teamAdapter, { select: false, alert: false })\n const normalizedId = String(newAgentId || '').trim()\n if (!normalizedId) throw new Error(`${zh ? 'Clone 未返回 agent_id' : 'Clone did not return agent_id'}: ${role.label}`)\n createdAgentIds.push(normalizedId)\n }\n\n setTeamProgress(zh ? '正在创建团队 Topic...' : 'Creating team topic...')\n const description = [\n zh ? template.description : template.descriptionEn,\n '',\n zh ? '团队工作流:' : 'Team workflow:',\n ...template.workflow.map((step, index) => `${index + 1}. ${step}`),\n ].join('\\n')\n const topicId = await createPrivateDiscussionTopic(name, description, createdAgentIds[0])\n await Promise.all(createdAgentIds.slice(1).map((agentId) => joinAgentToTopic(topicId, agentId)))\n await onBindingChanged?.()\n await onTopicsRefresh?.()\n onSelectTopic(topicId)\n setTeamOpen(false)\n } catch (error) {\n setTeamError(error instanceof Error ? error.message : (zh ? '创建团队失败。' : 'Failed to create team.'))\n } finally {\n setTeamBusy(false)\n setTeamProgress('')\n }\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {groupOpen && (\n
\n
\n
\n
\n

{zh ? '新建群聊' : 'New Group Chat'}

\n

\n {zh ? '选择你已绑定在各个主机上的 Agent,直接创建一个私有协作 Topic。' : 'Select your bound agents across hosts and create a private collaboration topic.'}\n

\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n \n \n
\n\n
\n
\n {zh ? '选择 Agent' : 'Select agents'}\n setGroupAgentIds(agentOptions.map((agent) => agent.agent_id))}\n disabled={groupBusy || agentOptions.length === 0}\n className=\"text-xs font-black text-amber-700 transition hover:text-amber-500 disabled:opacity-50 dark:text-amber-300\"\n >\n {zh ? '全选' : 'Select all'}\n \n
\n
\n {agentOptions.map((agent) => {\n const checked = groupAgentIds.includes(agent.agent_id)\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const role = agentRoleTemplateMap?.[agent.agent_id] || getAgentRoleTemplate(agentRoleMap?.[agent.agent_id])\n return (\n toggleGroupAgent(agent.agent_id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n checked\n ? 'border-amber-300 bg-amber-50 text-amber-900 ring-2 ring-amber-100 dark:border-amber-500/45 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-amber-200 hover:bg-amber-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/10'\n }`}\n >\n {agent.display_name || agent.agent_id}\n \n {role.shortLabel} · {runtime?.hostname || (zh ? '未知主机' : 'Unknown host')}\n \n \n )\n })}\n
\n
\n\n {groupError && (\n
\n {groupError}\n
\n )}\n\n
\n setGroupOpen(false)}\n disabled={groupBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createGroupChat()}\n disabled={groupBusy || groupAgentIds.length === 0 || !groupName.trim()}\n className=\"inline-flex items-center gap-2 rounded-xl bg-amber-600 px-3 py-2 text-sm font-black text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {groupBusy && }\n {groupBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建群聊' : 'Create group')}\n \n
\n
\n
\n )}\n\n {teamOpen && (\n
\n
\n
\n
\n

{zh ? '新建团队' : 'New Team'}

\n

\n {zh ? '选择团队模板后,系统会在同一主机 clone 最多 5 个专业 Agent,并创建一个协作群聊。' : 'Choose a template. WTT clones up to 5 role agents on one host and creates a collaboration topic.'}\n

\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n
{zh ? '团队模板' : 'Team template'}
\n
\n {TEAM_TEMPLATES.map((template) => {\n const active = template.id === teamTemplateId\n const roleNames = template.roles.slice(0, 5).map((roleId) => getAgentRoleTemplate(roleId).shortLabel).join(' / ')\n return (\n {\n setTeamTemplateId(template.id)\n setTeamName(zh ? template.title : template.titleEn)\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-900 ring-2 ring-fuchsia-100 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100 dark:ring-fuchsia-500/20'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-fuchsia-200 hover:bg-fuchsia-50/60 dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-fuchsia-500/30 dark:hover:bg-fuchsia-500/10'\n }`}\n >\n {zh ? template.title : template.titleEn}\n {zh ? template.description : template.descriptionEn}\n {roleNames}\n \n )\n })}\n
\n
\n\n
\n \n\n \n\n
\n
Adapter
\n
\n {([\n ['claude-code', 'Claude'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = teamAdapter === id\n return (\n setTeamAdapter(id)}\n className={`rounded-xl border px-2 py-2 text-xs font-black transition ${\n active\n ? 'border-fuchsia-300 bg-fuchsia-50 text-fuchsia-800 dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15 dark:text-fuchsia-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n {selectedTeamTemplate && (\n
\n

{zh ? '工作流' : 'Workflow'}

\n
\n {selectedTeamTemplate.workflow.map((step, index) => (\n

{index + 1}. {step}

\n ))}\n
\n
\n )}\n\n {teamProgress && (\n
\n {teamProgress}\n
\n )}\n {teamError && (\n
\n {teamError}\n
\n )}\n\n
\n setTeamOpen(false)}\n disabled={teamBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n void createTeamFromTemplate()}\n disabled={teamBusy || !teamHostId || !teamName.trim() || !onNewAgentFromHost}\n className=\"inline-flex items-center gap-2 rounded-xl bg-fuchsia-600 px-3 py-2 text-sm font-black text-white transition hover:bg-fuchsia-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {teamBusy && }\n {teamBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建团队' : 'Create team')}\n \n
\n
\n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "425e74165c95a2759ce6445ea03719bb00a6e893": {"t": 1785773608.7860115, "n": 95967, "h": "61fbaa7c11322ac2", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (hostAgentId: string, role: AgentRoleTemplate, adapter: 'claude-code' | 'codex' | 'gemini') => void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "61cccc23b5014bd689508006729cdecb1e1d7897": {"t": 1785773610.3890796, "n": 95968, "h": "d9e2986eb9c412fe", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (hostAgentId: string, role: AgentRoleTemplate, adapter: 'claude-code' | 'codex' | 'gemini') => void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n
\n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n
\n )}\n\n \n\n {roleEditor && (\n
\n
\n
\n

\n {roleEditor.custom ? (zh ? '新增自定义角色' : 'Add Custom Role') : (zh ? '编辑角色描述' : 'Edit Role Description')}\n

\n

\n {zh ? '保存后会同步到后端,并在该 Agent 下一次执行时作为隐式角色提示。' : 'Saved changes sync to the backend and are injected on the next agent action.'}\n

\n
\n \n \n
\n setRoleEditor(null)}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n \n {zh ? '保存并同步' : 'Save'}\n \n
\n
\n
\n )}\n\n {bindAgentOpen && (\n
\n
\n
\n
\n

\n {zh ? '绑定已有 Agent' : 'Bind Existing Agent'}\n

\n

\n {zh\n ? '生成一组 agent_id 和 token,然后在你自己的电脑、服务器或 Mac mini 上复制执行 wtt-connect 命令。'\n : 'Generate an agent_id/token pair, then run one wtt-connect command on your own computer, server, or Mac mini.'}\n

\n
\n setBindAgentOpen(false)}\n disabled={bindAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '关闭' : 'Close'}\n \n
\n\n
\n
\n setBindAgentDisplayName(event.target.value)}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n placeholder={zh ? 'Agent 显示名(可选)' : 'Agent display name (optional)'}\n className=\"min-w-0 flex-1 rounded-xl border border-emerald-200 bg-white px-3 py-2 text-sm font-semibold text-slate-800 outline-none transition focus:border-emerald-400 disabled:opacity-60 dark:border-emerald-500/30 dark:bg-zinc-950 dark:text-zinc-100\"\n />\n void createSelfManagedAgentBinding()}\n disabled={bindAgentBusy || Boolean(bindAgentCreds)}\n className=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-60\"\n >\n {bindAgentBusy && }\n {bindAgentCreds\n ? (zh ? '凭证已生成' : 'Credentials created')\n : bindAgentBusy\n ? (zh ? '生成中...' : 'Creating...')\n : (zh ? '生成 agent_id / token' : 'Generate agent_id / token')}\n \n
\n {bindAgentError && (\n
\n {bindAgentError}\n
\n )}\n
\n\n {!bindAgentCreds ? (\n
\n
\n

{zh ? '第一步' : 'Step 1'}

\n

npm install -g wtt-connect

\n

\n {zh ? '在要运行 Agent 的那台主机上安装。' : 'Run this on the host where the Agent should live.'}\n

\n
\n
\n

{zh ? '第二步' : 'Step 2'}

\n

\n {zh ? '选择 Codex / Claude Code / Gemini 启动' : 'Choose Codex / Claude Code / Gemini'}\n

\n

\n {zh ? '一个 Agent 只能绑定一个 adapter。生成凭证后选择其中一种命令执行。' : 'One Agent can bind to only one adapter. After generating credentials, choose exactly one command to run.'}\n

\n
\n
\n ) : (\n
\n
\n
\n

agent_id

\n {bindAgentCreds.agent_id}\n
\n
\n

agent_token

\n {bindAgentCreds.agent_token}\n
\n
\n\n
\n {zh\n ? '注意:一个 agent_id/token 只能启动一个 adapter。请选择 Codex、Claude Code 或 Gemini 中的一个,不要同时执行多个启动命令。'\n : 'Note: one agent_id/token should start one adapter only. Choose Codex, Claude Code, or Gemini. Do not run multiple startup commands for the same agent.'}\n
\n\n
\n {WTT_CONNECT_ADAPTERS.map((adapter) => {\n const active = bindAgentAdapter === adapter.id\n return (\n {\n setBindAgentAdapter(adapter.id)\n setBindAgentCopied('')\n }}\n className={`rounded-2xl border p-3 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-900 ring-2 ring-emerald-100 dark:border-emerald-400/50 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-500/20'\n : 'border-[#eee6da] bg-white/80 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50/60 dark:border-zinc-800 dark:bg-zinc-950/70 dark:text-zinc-300 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/10'\n }`}\n >\n {adapter.label}\n {adapter.note}\n \n )\n })}\n
\n\n
\n
\n
\n

\n {WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label}\n

\n

\n {zh ? '只复制并执行这一条命令。' : 'Copy and run this command only.'}\n

\n
\n void copyBindCommand(\n buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token),\n `${WTT_CONNECT_ADAPTERS.find((adapter) => adapter.id === bindAgentAdapter)?.label || bindAgentAdapter} ${zh ? '命令已复制' : 'command copied'}`,\n )}\n className=\"inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-black text-emerald-800 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100 dark:hover:bg-emerald-500/15\"\n >\n \n {zh ? '复制命令' : 'Copy command'}\n \n
\n {bindAgentCopied && (\n

{bindAgentCopied}

\n )}\n
\n                    {buildWttConnectCommand(bindAgentAdapter, bindAgentCreds.agent_id, bindAgentCreds.agent_token)}\n                  
\n
\n
\n )}\n
\n
\n )}\n\n {newAgentOpen && (\n
\n
\n
\n

\n {zh ? '克隆 Agent' : 'Clone Agent'}\n

\n

\n {zh ? '选择一个在线 Codex / Claude Code / Gemini 主机和角色。系统会自动 clone 一个 agent,并在该主机上启动独立默认 workspace。' : 'Choose an online Codex / Claude Code / Gemini host and role. WTT will clone an agent and start it with its own default workspace.'}\n

\n
\n\n \n\n
\n
{zh ? '运行 Adapter' : 'Adapter'}
\n
\n {([\n ['claude-code', 'Claude Code'],\n ['codex', 'Codex'],\n ['gemini', 'Gemini'],\n ] as const).map(([id, label]) => {\n const active = newAgentAdapter === id\n return (\n setNewAgentAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left text-sm font-black transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n \n )\n })}\n
\n
\n\n
\n
{zh ? '角色' : 'Role'}
\n
\n {AGENT_ROLE_TEMPLATES.map((template) => {\n const active = template.id === newAgentRoleId\n return (\n setNewAgentRoleId(template.id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-500/45 dark:bg-emerald-500/15 dark:text-emerald-200'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {template.label}\n {template.description}\n \n )\n })}\n
\n
\n\n {newAgentError && (\n
\n {newAgentError}\n
\n )}\n\n
\n setNewAgentOpen(false)}\n disabled={newAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createNewAgentFromHost()\n }}\n disabled={newAgentBusy || !newAgentHostId}\n className=\"rounded-xl bg-emerald-600 px-3 py-2 text-sm font-black text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {newAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '创建并启动' : 'Create & Start')}\n \n
\n
\n
\n )}\n\n {cloudCreateOpen && (\n
\n
\n
\n

\n {zh ? '创建 Cloud Agent' : 'Create Cloud Agent'}\n

\n

\n {zh\n ? '每个用户只有一个 Cloud Sandbox。需要更多 Agent 时,请用 Clone Agent 功能,它们会运行在同一个 Sandbox 的不同 workspace。'\n : 'Each user has one Cloud Sandbox. Use Clone Agent for more agents; they run in the same Sandbox with separate workspaces.'}\n

\n
\n\n \n\n
\n
{zh ? '默认运行时' : 'Runtime'}
\n
\n {([\n ['claude-code', 'DeepSeek + Claude Code', zh ? '默认推荐,每小时另加 ¥0.5;Pro 含 30 次连续请求、500 次/月。' : 'Default. Adds ¥0.5/hour; Pro includes 30 continuous requests and 500/month.'],\n ['gemini', 'Gemini', zh ? '可填 Key,也可创建后在终端登录/配置。' : 'Optional key, or configure in terminal later.'],\n ['codex', 'Codex', zh ? '可填 OpenAI Key,也可创建后在终端登录/配置。' : 'Optional OpenAI key, or configure in terminal later.'],\n ] as const).map(([id, label, desc]) => {\n const active = cloudCreateAdapter === id\n return (\n setCloudCreateAdapter(id)}\n className={`rounded-xl border px-3 py-2 text-left transition ${\n active\n ? 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/45 dark:bg-sky-500/15 dark:text-sky-100'\n : 'border-[#eee6da] bg-white/70 text-slate-600 hover:border-[#ded6c8] hover:bg-[#f3eee5] dark:border-zinc-800 dark:bg-zinc-950/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800'\n }`}\n >\n {label}\n {desc}\n \n )\n })}\n
\n
\n\n \n\n
\n {zh\n ? '计费提示:Cloud Sandbox 按开机使用时间计费,关机不计费。默认 DeepSeek + Claude Code 套餐包含模型代理成本,每小时额外 ¥0.5,Pro 额度为 30 次连续请求、本月共 500 次。'\n : 'Billing: Cloud Sandbox is billed while powered on; powered off is not billed. Default DeepSeek + Claude Code includes model proxy cost, adds ¥0.5/hour, and Pro includes 30 continuous requests and 500/month.'}\n
\n\n {cloudCreateError && (\n
\n {cloudCreateError}\n
\n )}\n\n
\n setCloudCreateOpen(false)}\n disabled={cloudAgentBusy}\n className=\"rounded-xl px-3 py-2 text-sm font-semibold text-slate-500 transition hover:bg-slate-100 disabled:opacity-60 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n >\n {zh ? '取消' : 'Cancel'}\n \n {\n void createCloudAgent()\n }}\n disabled={cloudAgentBusy}\n className=\"rounded-xl bg-sky-600 px-3 py-2 text-sm font-black text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {cloudAgentBusy ? (zh ? '创建中...' : 'Creating...') : (zh ? '确认创建' : 'Create')}\n \n
\n
\n
\n )}\n\n {shellAgent && (\n setShellAgent(null)}\n />\n )}\n \n )\n}\n"}, "bd65376246eabec66bb3925f0bad02b927939d48": {"t": 1785773612.3638127, "n": 95815, "h": "cedfe78e1d0905fa", "c": "'use client'\n\nimport { ChevronDown, ChevronRight, ClipboardCopy, ClipboardList, Cloud, Crown, Feather, Flame, Hash, Loader2, Lock, MessageCircle, MoreVertical, Plus, Power, Radio, Shield, Sparkles, Sun, Users, Waves, Zap } from 'lucide-react'\nimport { useEffect, useMemo, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'\nimport {\n AGENT_ROLE_TEMPLATES,\n buildRoleSystemPrompt,\n getAgentRoleTemplate,\n type AgentRoleTemplate,\n type AgentRoleTemplateId,\n} from '@/lib/agent-role-templates'\nimport { AgentTerminalModal } from '@/components/ui/agent-terminal-modal'\nimport { useI18n } from '@/lib/i18n-provider'\nimport { CLIENT_WTT_API_BASE } from '@/lib/api/base-url'\nimport { wttApi } from '@/lib/api/wtt-client'\n\nexport interface TopicItem {\n topic_id: string\n name: string\n topic_type: 'broadcast' | 'discussion' | 'p2p' | 'collaborative'\n unread_count?: number\n can_delete?: boolean\n task_id?: string\n task_type?: string\n task_mode?: string\n exec_mode?: string\n runner_agent_id?: string\n is_default_p2p?: boolean\n last_activity_at?: string\n description?: string\n creator_agent_id?: string\n}\n\ninterface AgentOption {\n agent_id: string\n display_name: string\n binding_method?: string\n bound_via?: string\n is_cloud_sandbox?: boolean\n cloud_host_agent_id?: string\n}\n\nexport type CloudAgentCreateOptions = {\n adapter: 'claude-code' | 'codex' | 'gemini'\n model: string\n providerPlan: 'deepseek' | 'claude' | 'gpt' | 'gemini'\n apiKey?: string\n displayName?: string\n}\n\nexport interface AgentRuntimeInfo {\n kind?: string\n adapter?: string\n model?: string\n model_id?: string\n current_model?: string\n reasoning_effort?: string\n workdir?: string\n workdir_name?: string\n hostname?: string\n platform?: string\n provider?: string\n host_agent_id?: string\n workspace_path?: string\n git?: {\n repo?: string\n branch?: string\n commit?: string\n dirty?: boolean\n } | null\n last_heartbeat_secs_ago?: number\n}\n\ninterface TopicColumnProps {\n topics: TopicItem[]\n selectedTopicId: string | null\n onSelectTopic: (topicId: string | null) => void\n onLeaveTopic?: (topicId: string) => void\n onDeleteTopic?: (topicId: string) => void\n onCreateP2P?: (targetAgentId: string) => void | Promise\n onRequestDiscuss?: (targetAgentId: string, topicName: string) => void | Promise\n onSelectWorkerTopic?: (topicId: string, workerSession?: { workerId: string; personaMd: string; workerMd: string; isFirstSession: boolean; personaChanged?: boolean }) => void\n pinScopeKey?: string\n agentOptions?: AgentOption[]\n selectedAgentId?: string\n onSelectAgent?: (agentId: string) => void\n isSelectedAgentOnline?: boolean\n onlineAgentIds?: Set\n agentRoleMap?: Record\n agentRoleTemplateMap?: Record\n agentRuntimeMap?: Record\n onAssignAgentRole?: (agentId: string, roleId: AgentRoleTemplateId) => void\n onSaveAgentRole?: (agentId: string, role: AgentRoleTemplate) => void\n onNewAgentFromHost?: (hostAgentId: string, role: AgentRoleTemplate, adapter: 'claude-code' | 'codex' | 'gemini') => void | Promise\n onCreateCloudAgent?: (options?: CloudAgentCreateOptions) => void | Promise\n onSleepSandbox?: (hostAgentId: string) => void | Promise\n onWakeSandbox?: (hostAgentId: string) => void | Promise\n onRenameAgent?: (agentId: string, currentName: string) => void\n onUnclaimAgent?: (agentId: string) => void\n onBindingChanged?: () => void | Promise\n onCreateGeneralTask?: () => void\n onToggleSidebar?: () => void\n onStartAgentResize?: (event: ReactPointerEvent) => void\n localLibrarySlot?: ReactNode\n userToken?: string\n compactLayout?: boolean\n}\n\nfunction agentInitial(name: string) {\n return (name.trim()[0] || 'A').toUpperCase()\n}\n\ntype WttConnectAdapterId = 'codex' | 'claude-code' | 'gemini'\n\ntype ProvisionedWttConnectAgent = {\n agent_id: string\n agent_token: string\n}\n\nconst WTT_CONNECT_ADAPTERS: Array<{ id: WttConnectAdapterId; label: string; note: string }> = [\n { id: 'codex', label: 'Codex', note: '使用 Codex CLI,本机登录 ChatGPT/OpenAI 后启动。' },\n { id: 'claude-code', label: 'Claude Code', note: '使用 Claude Code,可走本机订阅或 WTT LLM Proxy。' },\n { id: 'gemini', label: 'Gemini CLI', note: 'Gemini 通常需要先在该主机完成 Google OAuth。' },\n]\n\nfunction shellQuote(value: string) {\n return `'${String(value).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction buildWttConnectCommand(adapter: WttConnectAdapterId, agentId: string, agentToken: string) {\n const aid = shellQuote(agentId)\n const tok = shellQuote(agentToken)\n const authHint = adapter === 'gemini'\n ? [\n '# 如果 Gemini 还没有授权,先运行一次:',\n 'gemini',\n '',\n ]\n : []\n return [\n '# Step 1: install / update wtt-connect',\n 'npm install -g wtt-connect',\n '',\n ...authHint,\n `# Step 2: start ${adapter} agent`,\n `wtt-connect up ${adapter} ${aid} ${tok}`,\n '',\n '# verify',\n `wtt-connect status ${agentId}-${adapter}`,\n `wtt-connect logs ${agentId}-${adapter} --lines 100`,\n ].join('\\n')\n}\n\nconst ROLE_TONES = [\n {\n avatar: 'bg-sky-500 text-white',\n selected: 'border-sky-300 bg-sky-50 shadow-sm dark:border-sky-500/45 dark:bg-sky-500/15',\n idle: 'hover:border-sky-200 hover:bg-sky-50/80 dark:hover:border-sky-500/35 dark:hover:bg-sky-500/10',\n label: 'text-sky-700 dark:text-sky-200',\n ring: 'ring-sky-100 dark:ring-sky-500/25',\n },\n {\n avatar: 'bg-emerald-500 text-white',\n selected: 'border-emerald-300 bg-emerald-50 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-500/15',\n idle: 'hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:border-emerald-500/35 dark:hover:bg-emerald-500/10',\n label: 'text-emerald-700 dark:text-emerald-200',\n ring: 'ring-emerald-100 dark:ring-emerald-500/25',\n },\n {\n avatar: 'bg-violet-500 text-white',\n selected: 'border-violet-300 bg-violet-50 shadow-sm dark:border-violet-500/45 dark:bg-violet-500/15',\n idle: 'hover:border-violet-200 hover:bg-violet-50/80 dark:hover:border-violet-500/35 dark:hover:bg-violet-500/10',\n label: 'text-violet-700 dark:text-violet-200',\n ring: 'ring-violet-100 dark:ring-violet-500/25',\n },\n {\n avatar: 'bg-amber-500 text-white',\n selected: 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-500/45 dark:bg-amber-500/15',\n idle: 'hover:border-amber-200 hover:bg-amber-50/80 dark:hover:border-amber-500/35 dark:hover:bg-amber-500/10',\n label: 'text-amber-700 dark:text-amber-200',\n ring: 'ring-amber-100 dark:ring-amber-500/25',\n },\n {\n avatar: 'bg-rose-500 text-white',\n selected: 'border-rose-300 bg-rose-50 shadow-sm dark:border-rose-500/45 dark:bg-rose-500/15',\n idle: 'hover:border-rose-200 hover:bg-rose-50/80 dark:hover:border-rose-500/35 dark:hover:bg-rose-500/10',\n label: 'text-rose-700 dark:text-rose-200',\n ring: 'ring-rose-100 dark:ring-rose-500/25',\n },\n {\n avatar: 'bg-cyan-600 text-white',\n selected: 'border-cyan-300 bg-cyan-50 shadow-sm dark:border-cyan-500/45 dark:bg-cyan-500/15',\n idle: 'hover:border-cyan-200 hover:bg-cyan-50/80 dark:hover:border-cyan-500/35 dark:hover:bg-cyan-500/10',\n label: 'text-cyan-700 dark:text-cyan-200',\n ring: 'ring-cyan-100 dark:ring-cyan-500/25',\n },\n {\n avatar: 'bg-fuchsia-500 text-white',\n selected: 'border-fuchsia-300 bg-fuchsia-50 shadow-sm dark:border-fuchsia-500/45 dark:bg-fuchsia-500/15',\n idle: 'hover:border-fuchsia-200 hover:bg-fuchsia-50/80 dark:hover:border-fuchsia-500/35 dark:hover:bg-fuchsia-500/10',\n label: 'text-fuchsia-700 dark:text-fuchsia-200',\n ring: 'ring-fuchsia-100 dark:ring-fuchsia-500/25',\n },\n] as const\n\nconst HOST_FOLDER_TONES = [\n {\n god: 'Zeus',\n Icon: Zap,\n shell: 'border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-teal-50 text-emerald-800 ring-emerald-100 dark:border-emerald-500/25 dark:from-emerald-500/15 dark:via-zinc-950 dark:to-teal-500/10 dark:text-emerald-100 dark:ring-emerald-500/15',\n icon: 'from-amber-300 via-yellow-500 to-orange-600 text-white shadow-amber-900/30',\n badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-100',\n },\n {\n god: 'Poseidon',\n Icon: Waves,\n shell: 'border-sky-200 bg-gradient-to-br from-sky-50 via-white to-cyan-50 text-sky-800 ring-sky-100 dark:border-sky-500/25 dark:from-sky-500/15 dark:via-zinc-950 dark:to-cyan-500/10 dark:text-sky-100 dark:ring-sky-500/15',\n icon: 'from-cyan-300 via-sky-500 to-blue-700 text-white shadow-sky-900/30',\n badge: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-100',\n },\n {\n god: 'Athena',\n Icon: Shield,\n shell: 'border-amber-200 bg-gradient-to-br from-amber-50 via-white to-orange-50 text-amber-900 ring-amber-100 dark:border-amber-500/25 dark:from-amber-500/15 dark:via-zinc-950 dark:to-orange-500/10 dark:text-amber-100 dark:ring-amber-500/15',\n icon: 'from-stone-300 via-indigo-500 to-slate-800 text-white shadow-indigo-900/30',\n badge: 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-100',\n },\n {\n god: 'Aphrodite',\n Icon: Sparkles,\n shell: 'border-rose-200 bg-gradient-to-br from-rose-50 via-white to-pink-50 text-rose-800 ring-rose-100 dark:border-rose-500/25 dark:from-rose-500/15 dark:via-zinc-950 dark:to-pink-500/10 dark:text-rose-100 dark:ring-rose-500/15',\n icon: 'from-pink-300 via-rose-500 to-fuchsia-700 text-white shadow-rose-900/30',\n badge: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-100',\n },\n {\n god: 'Apollo',\n Icon: Sun,\n shell: 'border-orange-200 bg-gradient-to-br from-orange-50 via-white to-yellow-50 text-orange-900 ring-orange-100 dark:border-orange-500/25 dark:from-orange-500/15 dark:via-zinc-950 dark:to-yellow-500/10 dark:text-orange-100 dark:ring-orange-500/15',\n icon: 'from-yellow-200 via-orange-500 to-red-600 text-white shadow-orange-900/30',\n badge: 'bg-orange-100 text-orange-800 dark:bg-orange-500/15 dark:text-orange-100',\n },\n {\n god: 'Hermes',\n Icon: Feather,\n shell: 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-sky-50 text-violet-900 ring-violet-100 dark:border-violet-500/25 dark:from-violet-500/15 dark:via-zinc-950 dark:to-sky-500/10 dark:text-violet-100 dark:ring-violet-500/15',\n icon: 'from-violet-300 via-indigo-500 to-sky-600 text-white shadow-indigo-900/30',\n badge: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-100',\n },\n {\n god: 'Hephaestus',\n Icon: Flame,\n shell: 'border-red-200 bg-gradient-to-br from-red-50 via-white to-stone-50 text-red-900 ring-red-100 dark:border-red-500/25 dark:from-red-500/15 dark:via-zinc-950 dark:to-stone-500/10 dark:text-red-100 dark:ring-red-500/15',\n icon: 'from-red-400 via-orange-600 to-stone-900 text-white shadow-red-900/30',\n badge: 'bg-red-100 text-red-800 dark:bg-red-500/15 dark:text-red-100',\n },\n {\n god: 'Hera',\n Icon: Crown,\n shell: 'border-fuchsia-200 bg-gradient-to-br from-fuchsia-50 via-white to-purple-50 text-fuchsia-900 ring-fuchsia-100 dark:border-fuchsia-500/25 dark:from-fuchsia-500/15 dark:via-zinc-950 dark:to-purple-500/10 dark:text-fuchsia-100 dark:ring-fuchsia-500/15',\n icon: 'from-fuchsia-300 via-purple-600 to-indigo-900 text-white shadow-purple-900/30',\n badge: 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-500/15 dark:text-fuchsia-100',\n },\n] as const\n\nfunction hashText(value: string) {\n let hash = 0\n for (let index = 0; index < value.length; index += 1) {\n hash = (hash * 31 + value.charCodeAt(index)) >>> 0\n }\n return hash\n}\n\nfunction roleTone(agentId: string, role?: AgentRoleTemplate) {\n const key = `${role?.id || role?.label || 'agent'}:${agentId}`\n return ROLE_TONES[hashText(key) % ROLE_TONES.length] || ROLE_TONES[0]\n}\n\nfunction hostFolderTone(key: string) {\n return HOST_FOLDER_TONES[hashText(key) % HOST_FOLDER_TONES.length] || HOST_FOLDER_TONES[0]\n}\n\nfunction agentTooltip(agent: AgentOption, role: AgentRoleTemplate, runtime?: AgentRuntimeInfo) {\n const workdir = runtime?.workdir || runtime?.git?.repo || runtime?.workdir_name || ''\n const git = runtime?.git\n return [\n agent.display_name || agent.agent_id,\n `${role.label} / ${role.shortLabel}`,\n `agentID: ${agent.agent_id}`,\n workdir ? `workdir: ${workdir}` : '',\n git?.branch ? `branch: ${git.branch}${git.dirty ? ' (dirty)' : ''}` : '',\n runtime?.adapter || runtime?.kind ? `adapter: ${runtime.adapter || runtime.kind}` : '',\n runtime?.hostname ? `host: ${runtime.hostname}` : '',\n ].filter(Boolean).join('\\n')\n}\n\nfunction stripTaskPrefix(name: string): string {\n return name.replace(/^TASK-[a-f0-9]{8}\\s*/i, '')\n}\n\nfunction getTopicIcon(topic: TopicItem) {\n if (topic.task_id) return ClipboardList\n switch (topic.topic_type) {\n case 'p2p':\n return Lock\n case 'collaborative':\n return Users\n case 'broadcast':\n return Radio\n default:\n return Hash\n }\n}\n\nfunction getTopicKindLabel(topic: TopicItem, zh: boolean) {\n if (topic.task_id) return zh ? '任务' : 'Task'\n switch (topic.topic_type) {\n case 'p2p':\n return 'P2P'\n case 'collaborative':\n return zh ? '协作' : 'Collaborative'\n case 'broadcast':\n return zh ? '订阅' : 'Broadcast'\n default:\n return zh ? '讨论' : 'Discuss'\n }\n}\n\nfunction getTopicDisplayName(topic: TopicItem) {\n const name = topic.task_id ? stripTaskPrefix(topic.name) : topic.name\n return topic.topic_type === 'p2p' ? name.replace(/^Worker:\\s*/i, '') : name\n}\n\ntype TopicGroupKey = 'p2p' | 'task' | 'discuss' | 'subscriber'\n\nfunction getTopicGroup(topic: TopicItem): TopicGroupKey {\n if (topic.topic_type === 'p2p') return 'p2p'\n if (topic.task_id) return 'task'\n if (topic.topic_type === 'broadcast') return 'subscriber'\n return 'discuss'\n}\n\nfunction getGroupLabel(group: TopicGroupKey, zh: boolean) {\n switch (group) {\n case 'p2p':\n return zh ? 'P2P 私聊' : 'P2P'\n case 'task':\n return zh ? '任务' : 'Tasks'\n case 'discuss':\n return zh ? '讨论' : 'Discuss'\n case 'subscriber':\n return zh ? '订阅' : 'Subscriptions'\n }\n}\n\nfunction getGroupIcon(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return Lock\n case 'task':\n return ClipboardList\n case 'discuss':\n return MessageCircle\n case 'subscriber':\n return Radio\n }\n}\n\nfunction getGroupTone(group: TopicGroupKey) {\n switch (group) {\n case 'p2p':\n return 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-500/20 dark:bg-indigo-500/10 dark:text-indigo-200'\n case 'task':\n return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200'\n case 'discuss':\n return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200'\n case 'subscriber':\n return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200'\n }\n}\n\nfunction formatRuntime(runtime?: AgentRuntimeInfo) {\n if (!runtime) return ''\n const workdir = runtime.workdir || runtime.git?.repo || runtime.workdir_name || ''\n const branch = runtime.git?.branch || ''\n const adapter = runtime.adapter || runtime.kind || ''\n const model = runtime.current_model || runtime.model_id || runtime.model || ''\n return [adapter, model, workdir, branch].filter(Boolean).join(' · ')\n}\n\nfunction normalizeNewAgentAdapter(runtime?: AgentRuntimeInfo) {\n const raw = String(runtime?.adapter || runtime?.kind || '').trim().toLowerCase()\n if (raw === 'codex') return 'codex'\n if (raw === 'claude' || raw === 'claude-code' || raw === 'claude_code') return 'claude-code'\n if (raw === 'gemini' || raw === 'gemini-cli') return 'gemini'\n return ''\n}\n\ntype AgentFolder = {\n key: string\n label: string\n subtitle: string\n agents: AgentOption[]\n}\n\nconst AGENT_RUNTIME_HOST_CACHE_KEY = 'wtt:last-agent-runtime-hosts:v1'\n\nfunction cleanHostLabel(value: unknown) {\n return String(value || '').trim()\n}\n\nfunction isCloudSandboxRuntime(runtime?: AgentRuntimeInfo) {\n const provider = String(runtime?.provider || '').trim().toLowerCase()\n return provider.startsWith('cloudflare_sandbox')\n}\n\nfunction cloudHostAgentIdForAgent(agent: AgentOption, runtime?: AgentRuntimeInfo) {\n const explicitHostId = cleanHostLabel(agent.cloud_host_agent_id)\n if (explicitHostId) return explicitHostId\n\n const runtimeHostId = cleanHostLabel(runtime?.host_agent_id)\n if (agent.is_cloud_sandbox || isCloudSandboxRuntime(runtime)) {\n return runtimeHostId || agent.agent_id\n }\n\n return ''\n}\n\nfunction hasRuntimeHostIdentity(runtime?: AgentRuntimeInfo) {\n return Boolean(\n cleanHostLabel(runtime?.hostname) ||\n cleanHostLabel(runtime?.host_agent_id) ||\n cleanHostLabel(runtime?.provider) ||\n cleanHostLabel(runtime?.workdir),\n )\n}\n\nfunction runtimeHostSnapshot(runtime: AgentRuntimeInfo): AgentRuntimeInfo {\n return {\n kind: runtime.kind,\n adapter: runtime.adapter,\n model: runtime.model,\n model_id: runtime.model_id,\n current_model: runtime.current_model,\n reasoning_effort: runtime.reasoning_effort,\n workdir: runtime.workdir,\n workdir_name: runtime.workdir_name,\n hostname: runtime.hostname,\n platform: runtime.platform,\n provider: runtime.provider,\n host_agent_id: runtime.host_agent_id,\n workspace_path: runtime.workspace_path,\n git: runtime.git,\n }\n}\n\nfunction mergeRuntimeWithCachedHost(live?: AgentRuntimeInfo, cached?: AgentRuntimeInfo): AgentRuntimeInfo | undefined {\n if (!live) return cached\n if (!cached) return live\n return {\n ...cached,\n ...live,\n hostname: cleanHostLabel(live.hostname) || cached.hostname,\n platform: cleanHostLabel(live.platform) || cached.platform,\n provider: cleanHostLabel(live.provider) || cached.provider,\n host_agent_id: cleanHostLabel(live.host_agent_id) || cached.host_agent_id,\n workdir: cleanHostLabel(live.workdir) || cached.workdir,\n workdir_name: cleanHostLabel(live.workdir_name) || cached.workdir_name,\n workspace_path: cleanHostLabel(live.workspace_path) || cached.workspace_path,\n git: live.git || cached.git,\n }\n}\n\nexport function TopicColumn(props: TopicColumnProps) {\n const {\n topics,\n selectedTopicId,\n onSelectTopic,\n onLeaveTopic,\n onDeleteTopic,\n agentOptions = [],\n selectedAgentId = '',\n onSelectAgent,\n isSelectedAgentOnline = false,\n onlineAgentIds,\n agentRoleMap,\n agentRoleTemplateMap,\n agentRuntimeMap,\n onAssignAgentRole,\n onSaveAgentRole,\n onNewAgentFromHost,\n onCreateCloudAgent,\n onSleepSandbox,\n onWakeSandbox,\n onRenameAgent,\n onUnclaimAgent,\n onBindingChanged,\n onCreateGeneralTask,\n onToggleSidebar,\n onStartAgentResize,\n userToken,\n compactLayout = false,\n } = props\n const [agentMenuFor, setAgentMenuFor] = useState(null)\n const [folderMenuFor, setFolderMenuFor] = useState(null)\n const [sandboxActionFor, setSandboxActionFor] = useState(null)\n const [topicMenuFor, setTopicMenuFor] = useState(null)\n const [mentionMuteByTopic, setMentionMuteByTopic] = useState>({})\n const [shellAgent, setShellAgent] = useState(null)\n const [newAgentOpen, setNewAgentOpen] = useState(false)\n const [newAgentHostId, setNewAgentHostId] = useState('')\n const [newAgentRoleId, setNewAgentRoleId] = useState('general')\n const [newAgentAdapter, setNewAgentAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [newAgentBusy, setNewAgentBusy] = useState(false)\n const [newAgentError, setNewAgentError] = useState('')\n const [cloudCreateOpen, setCloudCreateOpen] = useState(false)\n const [cloudCreateAdapter, setCloudCreateAdapter] = useState<'claude-code' | 'codex' | 'gemini'>('claude-code')\n const [cloudCreateApiKey, setCloudCreateApiKey] = useState('')\n const [cloudCreateDisplayName, setCloudCreateDisplayName] = useState('Cloud Agent')\n const [cloudCreateError, setCloudCreateError] = useState('')\n const [cloudAgentBusy, setCloudAgentBusy] = useState(false)\n const [bindAgentOpen, setBindAgentOpen] = useState(false)\n const [bindAgentDisplayName, setBindAgentDisplayName] = useState('')\n const [bindAgentAdapter, setBindAgentAdapter] = useState('codex')\n const [bindAgentBusy, setBindAgentBusy] = useState(false)\n const [bindAgentError, setBindAgentError] = useState('')\n const [bindAgentCopied, setBindAgentCopied] = useState('')\n const [bindAgentCreds, setBindAgentCreds] = useState(null)\n const [roleEditor, setRoleEditor] = useState<{\n agentId: string\n sourceRole?: AgentRoleTemplate\n label: string\n description: string\n custom: boolean\n } | null>(null)\n const [collapsedGroups, setCollapsedGroups] = useState>({\n p2p: false,\n task: false,\n discuss: false,\n subscriber: false,\n })\n const [collapsedAgentFolders, setCollapsedAgentFolders] = useState>({})\n const { locale, t } = useI18n()\n const zh = locale === 'zh'\n const [runtimeHostCache, setRuntimeHostCache] = useState>(() => {\n if (typeof window === 'undefined') return {}\n try {\n const parsed = JSON.parse(window.localStorage.getItem(AGENT_RUNTIME_HOST_CACHE_KEY) || '{}')\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}\n } catch {\n return {}\n }\n })\n\n useEffect(() => {\n if (!agentRuntimeMap || Object.keys(agentRuntimeMap).length === 0) return\n setRuntimeHostCache((prev) => {\n let changed = false\n const next = { ...prev }\n\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap)) {\n if (!runtime || !hasRuntimeHostIdentity(runtime)) continue\n const snapshot = runtimeHostSnapshot(runtime)\n if (JSON.stringify(next[agentId] || {}) === JSON.stringify(snapshot)) continue\n next[agentId] = snapshot\n changed = true\n }\n\n if (!changed) return prev\n try {\n window.localStorage.setItem(AGENT_RUNTIME_HOST_CACHE_KEY, JSON.stringify(next))\n } catch {\n // localStorage quota or privacy mode should not break the agent list.\n }\n return next\n })\n }, [agentRuntimeMap])\n\n const displayRuntimeMap = useMemo(() => {\n const merged: Record = { ...runtimeHostCache }\n for (const [agentId, runtime] of Object.entries(agentRuntimeMap || {})) {\n const mergedRuntime = mergeRuntimeWithCachedHost(runtime, runtimeHostCache[agentId])\n if (mergedRuntime) merged[agentId] = mergedRuntime\n }\n return merged\n }, [agentRuntimeMap, runtimeHostCache])\n\n useEffect(() => {\n const closeMenus = () => {\n setTopicMenuFor(null)\n setAgentMenuFor(null)\n setFolderMenuFor(null)\n }\n window.addEventListener('click', closeMenus)\n return () => window.removeEventListener('click', closeMenus)\n }, [])\n\n useEffect(() => {\n if (!topicMenuFor || !selectedAgentId) return\n const topic = topics.find((item) => item.topic_id === topicMenuFor)\n if (!topic || topic.task_id || !['discussion', 'collaborative'].includes(topic.topic_type)) return\n let cancelled = false\n wttApi.getTopicMentionMutes(topic.topic_id, selectedAgentId, selectedAgentId, userToken)\n .then((rows) => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: rows.length > 0 }))\n })\n .catch(() => {\n if (cancelled) return\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: false }))\n })\n return () => {\n cancelled = true\n }\n }, [topicMenuFor, selectedAgentId, topics, userToken])\n\n const toggleTopicMentionMute = async (topic: TopicItem) => {\n if (!selectedAgentId) return\n const nextMuted = !mentionMuteByTopic[topic.topic_id]\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: nextMuted }))\n setTopicMenuFor(null)\n try {\n await wttApi.setTopicMentionMute(topic.topic_id, selectedAgentId, selectedAgentId, nextMuted, userToken)\n } catch (error) {\n setMentionMuteByTopic((prev) => ({ ...prev, [topic.topic_id]: !nextMuted }))\n console.error('Failed to update topic mention mute', error)\n }\n }\n\n const saveRoleEditor = () => {\n if (!roleEditor || !onSaveAgentRole) return\n const label = roleEditor.label.trim()\n const description = roleEditor.description.trim()\n if (!label || !description) return\n const base = roleEditor.sourceRole\n const id = roleEditor.custom\n ? `custom-${Date.now().toString(36)}`\n : (base?.id || `custom-${Date.now().toString(36)}`)\n onSaveAgentRole(roleEditor.agentId, {\n id,\n label,\n shortLabel: label.slice(0, 8),\n description,\n skills: base?.skills?.length ? base.skills : ['custom_role'],\n systemPrompt: buildRoleSystemPrompt(label, description),\n })\n setRoleEditor(null)\n setAgentMenuFor(null)\n }\n\n const isAgentOnline = (agentId: string) => {\n if (onlineAgentIds) return onlineAgentIds.has(agentId)\n return agentId === selectedAgentId ? isSelectedAgentOnline : false\n }\n\n const newAgentHosts = useMemo(() => {\n if (!onNewAgentFromHost) return []\n return agentOptions.filter((agent) => {\n if (!isAgentOnline(agent.agent_id)) return false\n return Boolean(normalizeNewAgentAdapter(agentRuntimeMap?.[agent.agent_id]))\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [agentOptions, agentRuntimeMap, onlineAgentIds, selectedAgentId, isSelectedAgentOnline, onNewAgentFromHost])\n const hasCloudAgent = useMemo(\n () => agentOptions.some((agent) => (agent.binding_method || agent.bound_via || '') === 'cloud_trial'),\n [agentOptions],\n )\n\n const agentFolders = useMemo(() => {\n const folders = new Map()\n\n const ensureFolder = (key: string, label: string, subtitle: string) => {\n const existing = folders.get(key)\n if (existing) {\n if (!existing.label && label) existing.label = label\n if (!existing.subtitle && subtitle) existing.subtitle = subtitle\n return existing\n }\n const folder = { key, label, subtitle, agents: [] as AgentOption[] }\n folders.set(key, folder)\n return folder\n }\n\n for (const agent of agentOptions) {\n const runtime = displayRuntimeMap?.[agent.agent_id]\n const runtimeHost = cleanHostLabel(runtime?.hostname)\n\n if (runtimeHost) {\n ensureFolder(`host:${runtimeHost}`, runtimeHost, zh ? '主机' : 'Host').agents.push(agent)\n continue\n }\n\n ensureFolder('host:unknown', zh ? '未上报主机' : 'Unknown host', zh ? '无 hostname' : 'No hostname').agents.push(agent)\n }\n\n return Array.from(folders.values()).sort((a, b) => {\n const selectedA = a.agents.some((agent) => agent.agent_id === selectedAgentId)\n const selectedB = b.agents.some((agent) => agent.agent_id === selectedAgentId)\n if (selectedA && !selectedB) return -1\n if (!selectedA && selectedB) return 1\n return a.label.localeCompare(b.label)\n })\n }, [agentOptions, displayRuntimeMap, selectedAgentId, zh])\n\n const openNewAgentModal = () => {\n const selectedHost = newAgentHosts.find((agent) => agent.agent_id === selectedAgentId)\n const host = selectedHost || newAgentHosts[0]\n if (!host) return\n setNewAgentHostId(host.agent_id)\n setNewAgentAdapter((normalizeNewAgentAdapter(agentRuntimeMap?.[host.agent_id]) || 'claude-code') as 'claude-code' | 'codex' | 'gemini')\n setNewAgentRoleId('general')\n setNewAgentError('')\n setNewAgentOpen(true)\n }\n\n const createNewAgentFromHost = async () => {\n if (!onNewAgentFromHost || !newAgentHostId) return\n const role = AGENT_ROLE_TEMPLATES.find((template) => template.id === newAgentRoleId) || getAgentRoleTemplate(newAgentRoleId)\n setNewAgentBusy(true)\n setNewAgentError('')\n try {\n await onNewAgentFromHost(newAgentHostId, role, newAgentAdapter)\n setNewAgentOpen(false)\n } catch (error) {\n setNewAgentError(error instanceof Error ? error.message : (zh ? '创建失败' : 'Failed to create agent'))\n } finally {\n setNewAgentBusy(false)\n }\n }\n\n const cloudCreateProviderPlan = cloudCreateAdapter === 'claude-code' ? 'deepseek' : cloudCreateAdapter === 'codex' ? 'gpt' : 'gemini'\n const cloudCreateModel = cloudCreateAdapter === 'claude-code'\n ? 'deepseek-v4-pro[1m]'\n : cloudCreateAdapter === 'codex'\n ? 'codex-configured-by-user'\n : 'gemini-configured-by-user'\n\n const createCloudAgent = async () => {\n if (!onCreateCloudAgent || cloudAgentBusy) return\n setCloudCreateError('')\n setCloudAgentBusy(true)\n try {\n await onCreateCloudAgent({\n adapter: cloudCreateAdapter,\n model: cloudCreateModel,\n providerPlan: cloudCreateProviderPlan,\n apiKey: cloudCreateApiKey.trim() || undefined,\n displayName: cloudCreateDisplayName.trim() || 'Cloud Agent',\n })\n setCloudCreateOpen(false)\n setCloudCreateApiKey('')\n } catch (error) {\n setCloudCreateError(error instanceof Error ? error.message : (zh ? 'Cloud Agent 创建失败' : 'Cloud Agent creation failed'))\n } finally {\n setCloudAgentBusy(false)\n }\n }\n\n const openBindAgentModal = () => {\n setBindAgentDisplayName('')\n setBindAgentAdapter('codex')\n setBindAgentError('')\n setBindAgentCopied('')\n setBindAgentCreds(null)\n setBindAgentOpen(true)\n }\n\n const createSelfManagedAgentBinding = async () => {\n if (!userToken) {\n setBindAgentError(zh ? '登录已过期,请重新登录。' : 'Session expired. Please sign in again.')\n return\n }\n setBindAgentBusy(true)\n setBindAgentError('')\n setBindAgentCopied('')\n try {\n const response = await fetch(`${CLIENT_WTT_API_BASE}/agents/provision`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${userToken}`,\n },\n body: JSON.stringify({\n display_name: bindAgentDisplayName.trim() || 'Self-managed Agent',\n platform: 'openclaw',\n }),\n })\n const data = await response.json().catch(() => ({}))\n if (!response.ok) {\n const detail = typeof data.detail === 'string' ? data.detail : ''\n throw new Error(detail || (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n }\n const agentId = String(data.agent_id || '').trim()\n const agentToken = String(data.agent_token || '').trim()\n if (!agentId || !agentToken) {\n throw new Error(zh ? '后端未返回 agent_id 或 token。' : 'Backend did not return agent_id or token.')\n }\n setBindAgentCreds({ agent_id: agentId, agent_token: agentToken })\n void onBindingChanged?.()\n } catch (error) {\n setBindAgentError(error instanceof Error ? error.message : (zh ? '生成绑定凭证失败。' : 'Failed to generate binding credentials.'))\n } finally {\n setBindAgentBusy(false)\n }\n }\n\n const copyBindCommand = async (text: string, label: string) => {\n try {\n await navigator.clipboard.writeText(text)\n setBindAgentCopied(label)\n window.setTimeout(() => setBindAgentCopied(''), 1800)\n } catch {\n setBindAgentError(zh ? '复制失败,请手动复制命令。' : 'Copy failed. Please copy manually.')\n }\n }\n\n const runSandboxFolderAction = async (hostAgentId: string, action: 'sleep' | 'wake') => {\n const handler = action === 'sleep' ? onSleepSandbox : onWakeSandbox\n if (!handler || !hostAgentId) return\n setSandboxActionFor(`${hostAgentId}:${action}`)\n try {\n await handler(hostAgentId)\n setFolderMenuFor(null)\n } finally {\n setSandboxActionFor(null)\n }\n }\n\n const groupedTopics = useMemo(() => {\n const order: TopicGroupKey[] = ['p2p', 'task', 'discuss', 'subscriber']\n const byGroup = new Map()\n for (const group of order) byGroup.set(group, [])\n\n topics.forEach((topic) => {\n byGroup.get(getTopicGroup(topic))?.push(topic)\n })\n\n for (const group of order) {\n const rows = byGroup.get(group) || []\n rows.sort((a, b) => {\n if (a.is_default_p2p && !b.is_default_p2p) return -1\n if (!a.is_default_p2p && b.is_default_p2p) return 1\n\n const unreadDiff = Number(b.unread_count || 0) - Number(a.unread_count || 0)\n if (unreadDiff !== 0) return unreadDiff\n\n const at = a.last_activity_at ? new Date(a.last_activity_at).getTime() : 0\n const bt = b.last_activity_at ? new Date(b.last_activity_at).getTime() : 0\n return bt - at\n })\n }\n\n return order\n .map((group) => ({ group, items: byGroup.get(group) || [] }))\n }, [topics])\n\n const renderTopicRow = (topic: TopicItem) => {\n const selected = topic.topic_id === selectedTopicId\n const Icon = getTopicIcon(topic)\n const unread = Number(topic.unread_count || 0)\n const menuOpen = topicMenuFor === topic.topic_id\n const displayName = getTopicDisplayName(topic)\n const canToggleMentionMute = !!selectedAgentId && !topic.task_id && ['discussion', 'collaborative'].includes(topic.topic_type)\n const mentionMuted = !!mentionMuteByTopic[topic.topic_id]\n const selectedAgentName = agentOptions.find((agent) => agent.agent_id === selectedAgentId)?.display_name || selectedAgentId\n\n return (\n {\n event.preventDefault()\n setTopicMenuFor(topic.topic_id)\n }}\n >\n onSelectTopic(topic.topic_id)}\n className={`group flex w-full items-start gap-2 rounded-xl border px-2.5 py-2 text-left transition ${\n selected\n ? 'border-[#d7cbb9] bg-[#efe8dc] shadow-sm dark:border-emerald-500/35 dark:bg-emerald-500/10'\n : 'border-transparent bg-transparent hover:border-[#e4dccf] hover:bg-white/75 dark:hover:border-zinc-700 dark:hover:bg-zinc-900'\n }`}\n >\n \n \n \n \n \n \n {displayName}\n \n {topic.is_default_p2p && (\n \n default\n \n )}\n \n \n {getTopicKindLabel(topic, zh)}\n \n \n {unread > 0 && (\n \n {unread > 99 ? '99+' : unread}\n \n )}\n {\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n event.stopPropagation()\n setTopicMenuFor(menuOpen ? null : topic.topic_id)\n }\n }}\n className=\"mt-0.5 rounded-lg p-1 text-slate-400 opacity-60 transition hover:bg-[#f6f0e5] hover:text-slate-700 group-hover:opacity-100 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n title={zh ? 'Topic 设置' : 'Topic settings'}\n >\n \n \n \n\n {menuOpen && (\n event.stopPropagation()}\n >\n {canToggleMentionMute && (\n {\n void toggleTopicMentionMute(topic)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n title={selectedAgentName}\n >\n {mentionMuted ? (zh ? '开放 @' : 'Allow @') : (zh ? '屏蔽 @' : 'Mute @')}\n \n )}\n {onLeaveTopic && (\n {\n onLeaveTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-slate-600 transition hover:bg-[#f3eee5] dark:text-zinc-300 dark:hover:bg-zinc-800\"\n >\n {zh ? '离开 Topic' : 'Leave Topic'}\n \n )}\n {onDeleteTopic && topic.can_delete && (\n {\n onDeleteTopic(topic.topic_id)\n setTopicMenuFor(null)\n }}\n className=\"w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-red-500 transition hover:bg-red-50 dark:hover:bg-red-500/10\"\n >\n {zh ? '删除 Topic' : 'Delete Topic'}\n \n )}\n
\n )}\n \n )\n }\n\n return (\n <>\n \n\n {onStartAgentResize && (\n